Skip to content

Commit 143f4cc

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-21009-json-column-text-operators
2 parents 76d2fd5 + d34aa58 commit 143f4cc

24 files changed

Lines changed: 2484 additions & 897 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol): the layered read's code layer is empty for an item no package ships, whether or not a stored copy of it has been loaded into the registry (#21059)
6+
7+
Clause-②: no
8+
9+
The layered read, `GET /api/v1/meta/:type/:name/layers`, reports an item's code layer as the packaged definition, and as empty when no package ships the item and it exists only as a stored customization. For an item no package ships, it answered that correctly only until the stored copy had been loaded into the in-memory registry, for example by a restart's startup load or by a list read. After that, the code layer answered the stored copy, and the lock and provenance flags were derived from it. So the same read of the same item gave two answers, depending on what had been loaded.
10+
11+
The code layer now skips a stored copy the registry holds, using the tenant-authorship mark the startup load already puts on every stored copy it registers. An item no package ships has an empty code layer before and after the load, and its flags come from the stored layer both times. This includes a stored copy whose own content claims a package's provenance: a claim is not a packaged definition. The deprecated layers flag on the by-name read, `GET /api/v1/meta/:type/:name`, answers the same.
12+
13+
Packaged items keep their packaged code layer. An item registered at runtime with no package keeps its code layer, as before. The stored rows are not changed.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@objectstack/lint': patch
3+
---
4+
5+
fix(lint): a liveness finding's fix text is the row's `authorHint`, else the verdict's default hint, and never the row's internal ledger `note`, for every row class. Before this, a row that opted in with `authorWarn`, and an `experimental` row, with no `authorHint` printed its `note` as the fix text: on `app-showcase`, the two `liveness-planned-property` findings for `externalSharingModel` printed a 728-character maintainer note that cites a tracker id. They now print "Keep it — a consumer is being built against this property; it has no runtime effect yet." No rule id, message, severity or exit code changes, and a row that has an `authorHint` prints it exactly as before (#21096)
6+
7+
Clause-②: no
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
'@objectstack/driver-turso': minor
3+
'@objectstack/driver-sql': patch
4+
---
5+
6+
feat(driver-turso): the remote transport issues `auto_number` values (#21113)
7+
8+
Clause-②: yes (widening)
9+
10+
A `create()`, `bulkCreate()` or `upsert()` on the Turso REMOTE transport that
11+
leaves an `autonumber` field empty (`undefined`, `null` or `''`) now gets a
12+
generated value. It used to be refused with `NOT_IMPLEMENTED` / 501, so on a
13+
hosted tenant database — which is on this transport — no object declaring an
14+
`auto_number` field could get a new record at all. Nothing is declared anew in
15+
the spec or in the package exports; `supports.autonumber` stays `true` and is
16+
now honoured.
17+
18+
- The value comes from the same persistent `_objectstack_sequences` counter the
19+
local and embedded-replica transports use, rendered by the same format rules
20+
(`autonumberFormat` / `format`, organization scope, date and `{field}`
21+
tokens), bootstrapped from the table's highest existing value by the same
22+
reading, and re-seeded the same way after rows land above the counter by a
23+
seed replay or import. A remote driver and an embedded replica of one
24+
database draw from one counter row.
25+
- The counter moves in one statement over the connection (`UPDATE … RETURNING`,
26+
or on a cold counter `INSERT … ON CONFLICT (key_hash) DO UPDATE … RETURNING`),
27+
so writers in different processes never draw the same number.
28+
- An `upsert()` that merges into an existing row keeps the number already in
29+
the row; a row that carries its own number is written unchanged.
30+
- A `_objectstack_sequences` table in the pre-`key_hash` shape is refused in
31+
remote mode with `DATABASE_ERROR` / 500 and the remedy in the message (open
32+
the database once through the local or embedded-replica transport, which
33+
migrates it); remote mode does not migrate it and does not key by the legacy
34+
rule.
35+
- `RemoteTransport.upsert()` takes an optional fifth argument naming columns
36+
that are written on insert and left alone on merge.
37+
38+
`@objectstack/driver-sql`: the sequence rules a second transport shares are
39+
now `protected` members of `SqlDriver` (`resolveSequenceTenantId`,
40+
`defineSequencesTable`, `maxAutonumberCounter`, `escapeLikePrefix`,
41+
`sequencesTableName`, `autoNumberCollisionRetries`). No export is added and no
42+
behaviour changes on any dialect.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
'@objectstack/service-storage': patch
3+
---
4+
5+
fix(service-storage): presigned S3 uploads no longer bake the CRC32 of an empty body into the signed URL (#21147)
6+
7+
Clause-②: no
8+
9+
`S3StorageAdapter.getPresignedUpload()` handed the browser a URL carrying
10+
`x-amz-sdk-checksum-algorithm=CRC32&x-amz-checksum-crc32=AAAAAA==`. `AAAAAA==`
11+
is the CRC32 of an empty body: the AWS SDK's default (`WHEN_SUPPORTED`) stamps a
12+
checksum into every `PutObjectCommand` it prepares, and a presign prepares the
13+
command before any byte exists. A store that enforces a query-signed checksum
14+
against the uploaded body refuses every presigned browser upload with a checksum
15+
mismatch, while server-side `upload()` keeps working. Hosted R2 does not enforce
16+
it, which is why this stayed quiet; self-hosted deployments on other
17+
S3-compatible stores may.
18+
19+
The adapter's `S3Client` now sets `requestChecksumCalculation` and
20+
`responseChecksumValidation` to `WHEN_REQUIRED`. None of the commands the adapter
21+
issues is checksum-required, so the presigned PUT URL carries no `x-amz-checksum-*`
22+
or `x-amz-sdk-checksum-algorithm` parameter, and a presigned GET URL no longer
23+
carries `x-amz-checksum-mode=ENABLED`.
24+
25+
Two server-side effects of the same setting, measured against a loopback server:
26+
`upload()` and multipart `uploadChunk()` no longer send the client-computed
27+
`x-amz-checksum-crc32` header (the SigV4 `x-amz-content-sha256` payload hash is
28+
unchanged), and `download()` no longer asks the store for, or validates, a
29+
response checksum. Both were added only by the SDK's default change; the older
30+
behaviour is restored. SDKs older than the flexible-checksum release ignore the
31+
two options.

‎packages/drivers/driver-sql/src/sql-driver.ts‎

Lines changed: 76 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7347,6 +7347,56 @@ export class SqlDriver implements IDataDriver {
73477347
.digest('hex');
73487348
}
73497349

7350+
// ── The sequence semantics a second face shares, stated once ──────────────
7351+
//
7352+
// `TursoDriver`'s REMOTE face issues record numbers over `@libsql/client`
7353+
// rather than through this class's Knex transaction, and an embedded-replica
7354+
// face and a remote face can point at ONE database. Two faces computing a
7355+
// different counter key, a different tenant bucket or a different data-table
7356+
// bootstrap would hand out colliding numbers on that database, so each rule
7357+
// below is a `protected` member that face CALLS — never a copy it keeps. The
7358+
// members are small on purpose: they are the whole of what decides which
7359+
// counter a value is drawn from and where a cold counter starts.
7360+
7361+
/** The sequence-counter table's name, for a face that spells its own statements against it. */
7362+
protected get sequencesTableName(): string {
7363+
return SEQUENCES_TABLE;
7364+
}
7365+
7366+
/** How many times a write re-seeds and retries on a provable autonumber collision (#5495). */
7367+
protected get autoNumberCollisionRetries(): number {
7368+
return AUTONUMBER_COLLISION_RETRIES;
7369+
}
7370+
7371+
/**
7372+
* The tenant bucket a counter row is keyed by: the row's tenant when the
7373+
* object HAS a tenant column and the row carries one, else the platform
7374+
* bucket `GLOBAL_TENANT`. One spelling for the issue path, the re-seed path
7375+
* and the counter identity, which used to carry it three times.
7376+
*/
7377+
protected resolveSequenceTenantId(tenantField: string | null, tenantId: string | null): string {
7378+
return tenantField && tenantId ? String(tenantId) : GLOBAL_TENANT;
7379+
}
7380+
7381+
/**
7382+
* The column definitions of the `key_hash`-keyed sequences table — the ONE
7383+
* place the shape is spelled. {@link createSequencesTable} runs it through a
7384+
* live connection; a face with no Knex connection compiles the same builder
7385+
* to text (`knex.schema.createTable(name, t => this.defineSequencesTable(t)).toSQL()`)
7386+
* and sends that, so both faces create byte-for-byte the same table.
7387+
*/
7388+
protected defineSequencesTable(t: Knex.CreateTableBuilder): void {
7389+
t.string('key_hash', 64).notNullable().primary();
7390+
t.string('object').notNullable();
7391+
t.string('tenant_id').notNullable();
7392+
t.string('field').notNullable();
7393+
// Non-indexed, so it is free of the PK length limit — a long `{plan_no}`
7394+
// composite scope fits. 1024 is far above any realistic rendered prefix.
7395+
t.string('scope', 1024).notNullable().defaultTo('');
7396+
t.bigInteger('last_value').notNullable().defaultTo(0);
7397+
t.timestamp('updated_at').defaultTo(this.knex.fn.now());
7398+
}
7399+
73507400
/**
73517401
* Create the current `key_hash`-keyed sequences table shape. `runner` is the
73527402
* connection the DDL runs on (a fresh pooled connection by default, or the
@@ -7356,17 +7406,7 @@ export class SqlDriver implements IDataDriver {
73567406
table: string,
73577407
runner: Knex | Knex.Transaction = this.knex,
73587408
): Promise<void> {
7359-
await runner.schema.createTable(table, (t) => {
7360-
t.string('key_hash', 64).notNullable().primary();
7361-
t.string('object').notNullable();
7362-
t.string('tenant_id').notNullable();
7363-
t.string('field').notNullable();
7364-
// Non-indexed, so it is free of the PK length limit — a long `{plan_no}`
7365-
// composite scope fits. 1024 is far above any realistic rendered prefix.
7366-
t.string('scope', 1024).notNullable().defaultTo('');
7367-
t.bigInteger('last_value').notNullable().defaultTo(0);
7368-
t.timestamp('updated_at').defaultTo(this.knex.fn.now());
7369-
});
7409+
await runner.schema.createTable(table, (t) => this.defineSequencesTable(t));
73707410
}
73717411

73727412
/**
@@ -7497,16 +7537,34 @@ export class SqlDriver implements IDataDriver {
74977537
tenantId: string | null,
74987538
suffix = '',
74997539
): Promise<number> {
7500-
const escapedPrefix = prefix.replace(/([\\%_])/g, '\\$1');
7501-
let builder = queryRunner(tableName).select(field).where(field, 'like', `${escapedPrefix}%`).whereNotNull(field);
7540+
let builder = queryRunner(tableName).select(field).where(field, 'like', `${this.escapeLikePrefix(prefix)}%`).whereNotNull(field);
75027541
if (tenantField && tenantId !== null) {
75037542
builder = builder.where(tenantField, tenantId);
75047543
}
75057544
const rows = await builder;
7545+
return this.maxAutonumberCounter((rows as any[]).map((r) => (r as any)[field]), prefix, suffix);
7546+
}
7547+
7548+
/**
7549+
* The rendered prefix as a `LIKE` anchor: `\`, `%` and `_` escaped with a
7550+
* backslash, so a prefix is matched literally. The predicate's pre-filter
7551+
* only — {@link maxAutonumberCounter} re-checks the prefix per row.
7552+
*/
7553+
protected escapeLikePrefix(prefix: string): string {
7554+
return prefix.replace(/([\\%_])/g, '\\$1');
7555+
}
7556+
7557+
/**
7558+
* The bootstrap reading itself — the highest counter among the stored values
7559+
* of one counter's partition, under the anchored / unanchored rules the
7560+
* {@link scanMaxNumericTail} docblock states. Separated from the statement
7561+
* that fetches the values so a face that fetches them through another
7562+
* transport reads them by this one rule rather than by a copy of it.
7563+
*/
7564+
protected maxAutonumberCounter(values: Iterable<unknown>, prefix: string, suffix: string): number {
75067565
let maxN = 0;
75077566
const anchored = prefix !== '' || suffix !== '';
7508-
for (const r of rows as any[]) {
7509-
const v: string = (r as any)[field];
7567+
for (const v of values) {
75107568
if (typeof v !== 'string') continue;
75117569
let n: number;
75127570
if (anchored) {
@@ -7647,7 +7705,7 @@ export class SqlDriver implements IDataDriver {
76477705
// locking on a second one (SQLite pool max=1). `initObjects` normally warms
76487706
// this up front, making the call a no-op — this only bites the lazy path.
76497707
await this.ensureSequencesTable(parentTrx);
7650-
const resolvedTenantId = tenantField && tenantId ? String(tenantId) : GLOBAL_TENANT;
7708+
const resolvedTenantId = this.resolveSequenceTenantId(tenantField, tenantId);
76517709
if (scope !== '' && !this.sequencesHasKeyHash) {
76527710
// The legacy sequences table could not be migrated to the key_hash shape,
76537711
// so it cannot represent per-scope counters. Fail with a clear, actionable
@@ -7907,8 +7965,7 @@ export class SqlDriver implements IDataDriver {
79077965
*/
79087966
protected async resyncSequenceToDataMax(reservation: AutoNumberReservation): Promise<void> {
79097967
await this.ensureSequencesTable();
7910-
const resolvedTenantId =
7911-
reservation.tenantField && reservation.tenantId ? String(reservation.tenantId) : GLOBAL_TENANT;
7968+
const resolvedTenantId = this.resolveSequenceTenantId(reservation.tenantField, reservation.tenantId);
79127969
const key = this.sequencesHasKeyHash
79137970
? { key_hash: this.sequenceKeyHash(reservation.tableName, resolvedTenantId, reservation.field, reservation.scope) }
79147971
: { object: reservation.tableName, tenant_id: resolvedTenantId, field: reservation.field };
@@ -7942,8 +7999,7 @@ export class SqlDriver implements IDataDriver {
79427999
* that drew from it. See {@link bulkCreate}.
79438000
*/
79448001
protected autoNumberCounterKey(reservation: AutoNumberReservation): string {
7945-
const resolvedTenantId =
7946-
reservation.tenantField && reservation.tenantId ? String(reservation.tenantId) : GLOBAL_TENANT;
8002+
const resolvedTenantId = this.resolveSequenceTenantId(reservation.tenantField, reservation.tenantId);
79478003
return this.sequenceKeyHash(reservation.tableName, resolvedTenantId, reservation.field, reservation.scope);
79488004
}
79498005

‎packages/drivers/driver-turso/README.md‎

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,6 @@ table lists the ones that answer differently from local mode.
130130
| Operation | Refused call | Use instead |
131131
|:---|:---|:---|
132132
| Transactions | `beginTransaction()`, `commit()`, `rollback()` (and their deprecated aliases `commitTransaction()` and `rollbackTransaction()`), and `options.transaction` passed to any `RemoteTransport` method in the tree above, to `aggregate()` or to `syncSchemasBatch()` | The local or embedded-replica transport, which run Knex transactions and honour `options.transaction` |
133-
| Record numbers | `create()`, `bulkCreate()`, and an `upsert()` with no `id`, `_id` or `conflictKeys`, when a row leaves an `autonumber` field empty (`undefined`, `null` or `''`) | The local or embedded-replica transport, which generate record numbers, or a value you supply, which is written unchanged |
134133
| Deferring schema DDL | `setDeferredDdl(true)`, which `os migrate plan` calls. `setDeferredDdl(false)` is accepted | Run the command against a local SQLite copy of the database (a `file:` URL) |
135134
| Schema drift detection | `detectManagedDrift()` | `os migrate plan` against a local SQLite copy of the database (a `file:` URL) |
136135
| Planning the ADR-0104 media column move | `planMediaColumnMove()`, the column step of `os migrate files-to-references` | The local or embedded-replica transport, which plan it |
@@ -147,18 +146,30 @@ table lists the ones that answer differently from local mode.
147146
reads that and does not call `beginTransaction()`: it runs the callback with
148147
no transaction and logs a warning once per datasource, or, called with
149148
`require: true`, throws `TransactionUnsupportedError` before the callback runs.
150-
- **Record numbers.** Remote mode never generates one. The check uses the
151-
`autonumber` fields this driver recorded when it synced the object's schema
152-
(`syncSchema`, `syncSchemasBatch` or `initObjects`). An `upsert()` that
153-
carries an `id`, `_id` or `conflictKeys` is not refused, because it may merge
154-
into an existing row. If it inserts instead, the row is written with the field
155-
empty and the driver logs a warning naming the row.
156149
- **Aggregation.** `aggregate()` called on the driver directly also refuses,
157150
with the same code, a `groupBy` entry that has a `dateGranularity`, and an
158151
`aggregations` entry with a non-empty `filter`. `engine.aggregate()` never
159152
sends either one to this driver: it fetches the rows and computes both in
160153
memory. The local transport also refuses a per-aggregation `filter`.
161154

155+
#### Record numbers in remote mode
156+
157+
An `autonumber` field a row leaves empty (`undefined`, `null` or `''`) is
158+
filled in remote mode as it is in local and embedded-replica mode: `create()`,
159+
`bulkCreate()` and `upsert()` issue the next value from the persistent
160+
`_objectstack_sequences` counter, rendered with the field's `autonumberFormat`
161+
(or `format`) and scoped per organization, per date token and per `{field}`
162+
token exactly as `SqlDriver` scopes it. The counter moves in one statement over
163+
the connection (`UPDATE … RETURNING`; on a cold counter, a bootstrap from the
164+
table's highest existing value followed by `INSERT … ON CONFLICT DO UPDATE …
165+
RETURNING`), so two processes writing to one database never draw the same
166+
number. A row that already carries a value keeps it (a seed replay or an
167+
import); an `upsert()` that merges into an existing row keeps the number
168+
already in the row. A `_objectstack_sequences` table in the legacy shape
169+
(created by a local-mode driver older than the `key_hash` column) is refused
170+
with `code: 'DATABASE_ERROR'` / `status: 500` and the remedy in the message;
171+
remote mode does not migrate it.
172+
162173
Answered in remote mode, differently from local mode:
163174

164175
- **`distinct()`** runs a `SELECT DISTINCT` on the remote database and answers

‎packages/drivers/driver-turso/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
"devDependencies": {
4848
"@types/node": "^26.6.3",
4949
"better-sqlite3": "^13.0.3",
50+
"tsx": "^4.23.12",
5051
"typescript": "^6.0.3",
5152
"vitest": "^4.1.11"
5253
},

‎packages/drivers/driver-turso/src/remote-transport.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1595,11 +1595,21 @@ export class RemoteTransport {
15951595
return rows[0] ?? null;
15961596
}
15971597

1598+
/**
1599+
* `insertOnlyColumns` — columns written on the INSERT leg and left alone on
1600+
* the MERGE leg, so a value the caller (or the driver, on its behalf) put on
1601+
* the row for a NEW row never overwrites the one an existing row already
1602+
* holds. The transport is told WHICH columns, not WHY: the driver decides
1603+
* from the schema it holds and this class does not (the same division
1604+
* `setFilterColumnSql` and `setTenantFieldResolver` draw). A column named
1605+
* here that is also a merge key is simply a merge key.
1606+
*/
15981607
async upsert(
15991608
object: string,
16001609
data: Record<string, unknown>,
16011610
conflictKeys?: string[],
16021611
table: string = object,
1612+
insertOnlyColumns: readonly string[] = [],
16031613
): Promise<Record<string, unknown>> {
16041614
await this.ensureConnected();
16051615

@@ -1618,7 +1628,7 @@ export class RemoteTransport {
16181628
const mergeKeys = conflictKeys && conflictKeys.length > 0 ? conflictKeys : ['id'];
16191629

16201630
// Build ON CONFLICT ... DO UPDATE SET
1621-
const updateCols = columns.filter((c) => !mergeKeys.includes(c));
1631+
const updateCols = columns.filter((c) => !mergeKeys.includes(c) && !insertOnlyColumns.includes(c));
16221632
const updateClauses = updateCols.map((col) => `"${col}" = excluded."${col}"`).join(', ');
16231633

16241634
let sql = `INSERT INTO ${this.tableSql(table)} (${columns.map((c) => `"${c}"`).join(', ')}) VALUES (${placeholders})`;

0 commit comments

Comments
 (0)