@syncular/server 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -40,7 +40,7 @@
40
40
  * rather than a lock D1 does not expose.
41
41
  */
42
42
  import { syncError } from './errors.js';
43
- import { commitWindowPageSql, deleteRowSql, layoutsOf, migratePayload, parseLayouts, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, tableColumnNames, upsertSql, upsertValues, } from './relational-rows.js';
43
+ import { commitWindowPageSql, deleteRowSql, dropTableDdl, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, tableColumnNames, upsertSql, upsertValues, } from './relational-rows.js';
44
44
  import { matchesEffective } from './scopes.js';
45
45
  import { asUint8Array, collectCommitWindowPage, deserializePushResult, serializePushResult, sqliteDdlStatements, toStoredRow, } from './sqlite-dialect.js';
46
46
  class D1Transaction {
@@ -307,6 +307,7 @@ export class D1ServerStorage {
307
307
  if (marker === null || marker.schema_version < schema.version) {
308
308
  await this.migrate();
309
309
  const layouts = parseLayouts(marker?.layouts);
310
+ const retiredTables = retiredTableNames(schema, layouts);
310
311
  const existing = new Map();
311
312
  for (const table of schema.tables.values()) {
312
313
  const { results } = await this.#db
@@ -331,6 +332,18 @@ export class D1ServerStorage {
331
332
  continue;
332
333
  await this.#rewriteRows(table, plan.migrate ? oldLayout : undefined);
333
334
  }
335
+ // Retire tables only after the additive DDL and rewrites succeed. D1
336
+ // cannot wrap the whole bump in an interactive transaction, but this
337
+ // ordering avoids destructive work before every fallible preparatory
338
+ // step and the batch keeps table + live-scope cleanup atomic.
339
+ if (retiredTables.length > 0) {
340
+ await this.#db.batch(retiredTables.flatMap((tableName) => [
341
+ this.#db
342
+ .prepare('DELETE FROM sync_row_scopes WHERE tbl=?')
343
+ .bind(tableName),
344
+ this.#db.prepare(dropTableDdl(tableName)),
345
+ ]));
346
+ }
334
347
  await this.#db
335
348
  .prepare('INSERT INTO sync_schema_meta(id, schema_version, layouts) VALUES (1, ?, ?) ON CONFLICT(id) DO UPDATE SET schema_version=excluded.schema_version, layouts=excluded.layouts')
336
349
  .bind(schema.version, layoutsOf(schema))
@@ -1,6 +1,6 @@
1
1
  import { syncError } from './errors.js';
2
2
  import { asBytes, asNumber, } from './pg-executor.js';
3
- import { commitWindowPageSql, deleteRowSql, layoutsOf, migratePayload, parseLayouts, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_POSTGRES, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
3
+ import { commitWindowPageSql, deleteRowSql, dropTableDdl, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_POSTGRES, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
4
4
  import { matchesEffective } from './scopes.js';
5
5
  /**
6
6
  * Schema DDL. Applied by `PostgresServerStorage.migrate()` (idempotent).
@@ -457,6 +457,7 @@ export class PostgresServerStorage {
457
457
  const layouts = parseLayouts(typeof marker.rows[0]?.layouts === 'string'
458
458
  ? marker.rows[0].layouts
459
459
  : undefined);
460
+ const retiredTables = retiredTableNames(schema, layouts);
460
461
  await this.#exec.transaction(async (client) => {
461
462
  const existing = new Map();
462
463
  for (const table of schema.tables.values()) {
@@ -466,6 +467,12 @@ export class PostgresServerStorage {
466
467
  existing.set(table.name, new Set(rows.map((r) => r.column_name)));
467
468
  }
468
469
  }
470
+ for (const tableName of retiredTables) {
471
+ await client.query('DELETE FROM sync_row_scopes WHERE tbl=$1', [
472
+ tableName,
473
+ ]);
474
+ await client.query(dropTableDdl(tableName));
475
+ }
469
476
  for (const statement of schemaDdl(schema, existing, 'postgres')) {
470
477
  await client.query(statement);
471
478
  }
@@ -178,6 +178,14 @@ export type StoredLayouts = Record<string, readonly StoredColumnLayout[]>;
178
178
  /** The layouts JSON persisted alongside the schema version marker. */
179
179
  export declare function layoutsOf(schema: CompiledSchema): string;
180
180
  export declare function parseLayouts(json: string | null | undefined): StoredLayouts;
181
+ /**
182
+ * Tables present in the stored layout but absent from the configured head
183
+ * schema. A schema-version bump retires their relational current-row tables;
184
+ * append-only commit history remains governed by the normal retention policy.
185
+ */
186
+ export declare function retiredTableNames(schema: CompiledSchema, storedLayouts: StoredLayouts): string[];
187
+ /** Idempotent DDL for one retired relational current-row table. */
188
+ export declare function dropTableDdl(tableName: string): string;
181
189
  /**
182
190
  * Enforce the migration subset on a table's column list: the old layout
183
191
  * must be an exact prefix (same name, type, nullability) of the new one,
@@ -381,6 +381,20 @@ export function parseLayouts(json) {
381
381
  return {};
382
382
  return JSON.parse(json);
383
383
  }
384
+ /**
385
+ * Tables present in the stored layout but absent from the configured head
386
+ * schema. A schema-version bump retires their relational current-row tables;
387
+ * append-only commit history remains governed by the normal retention policy.
388
+ */
389
+ export function retiredTableNames(schema, storedLayouts) {
390
+ return Object.keys(storedLayouts)
391
+ .filter((name) => !schema.tables.has(name))
392
+ .sort();
393
+ }
394
+ /** Idempotent DDL for one retired relational current-row table. */
395
+ export function dropTableDdl(tableName) {
396
+ return `DROP TABLE IF EXISTS ${quoteIdent(tableName)}`;
397
+ }
384
398
  /**
385
399
  * Enforce the migration subset on a table's column list: the old layout
386
400
  * must be an exact prefix (same name, type, nullability) of the new one,
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { Database } from 'bun:sqlite';
10
10
  import { syncError } from './errors.js';
11
- import { commitWindowPageSql, deleteRowSql, layoutsOf, migratePayload, parseLayouts, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
11
+ import { commitWindowPageSql, deleteRowSql, dropTableDdl, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
12
12
  import { matchesEffective } from './scopes.js';
13
13
  import { collectCommitWindowPage, deserializePushResult, SQLITE_DDL, serializePushResult, toStoredRow, } from './sqlite-dialect.js';
14
14
  class SqliteTransaction {
@@ -143,6 +143,7 @@ export class SqliteServerStorage {
143
143
  // projection backfill for flipped-on materialization). One
144
144
  // transaction: a failed bump leaves no half-state.
145
145
  const layouts = parseLayouts(marker?.layouts);
146
+ const retiredTables = retiredTableNames(schema, layouts);
146
147
  const existing = new Map();
147
148
  for (const table of schema.tables.values()) {
148
149
  const columns = this.db
@@ -154,6 +155,12 @@ export class SqliteServerStorage {
154
155
  }
155
156
  this.db.exec('BEGIN IMMEDIATE');
156
157
  try {
158
+ for (const tableName of retiredTables) {
159
+ this.db
160
+ .query('DELETE FROM sync_row_scopes WHERE tbl=?')
161
+ .run(tableName);
162
+ this.db.exec(dropTableDdl(tableName));
163
+ }
157
164
  for (const statement of schemaDdl(schema, existing, 'sqlite')) {
158
165
  this.db.exec(statement);
159
166
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/server",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "Syncular server: handleSyncRequest + storage/auth interfaces for the sync protocol",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -53,7 +53,7 @@
53
53
  "!dist/**/*.test.d.ts"
54
54
  ],
55
55
  "dependencies": {
56
- "@syncular/core": "0.11.0"
56
+ "@syncular/core": "0.13.0"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@electric-sql/pglite": "^0.5.4"
package/src/d1-storage.ts CHANGED
@@ -43,9 +43,11 @@ import { syncError } from './errors';
43
43
  import {
44
44
  commitWindowPageSql,
45
45
  deleteRowSql,
46
+ dropTableDdl,
46
47
  layoutsOf,
47
48
  migratePayload,
48
49
  parseLayouts,
50
+ retiredTableNames,
49
51
  rewritePlan,
50
52
  rewriteRowSql,
51
53
  rewriteValues,
@@ -489,6 +491,7 @@ export class D1ServerStorage implements ServerStorage {
489
491
  if (marker === null || marker.schema_version < schema.version) {
490
492
  await this.migrate();
491
493
  const layouts = parseLayouts(marker?.layouts);
494
+ const retiredTables = retiredTableNames(schema, layouts);
492
495
  const existing = new Map<string, ReadonlySet<string>>();
493
496
  for (const table of schema.tables.values()) {
494
497
  const { results } = await this.#db
@@ -512,6 +515,20 @@ export class D1ServerStorage implements ServerStorage {
512
515
  if (!plan.migrate && !plan.backfill) continue;
513
516
  await this.#rewriteRows(table, plan.migrate ? oldLayout : undefined);
514
517
  }
518
+ // Retire tables only after the additive DDL and rewrites succeed. D1
519
+ // cannot wrap the whole bump in an interactive transaction, but this
520
+ // ordering avoids destructive work before every fallible preparatory
521
+ // step and the batch keeps table + live-scope cleanup atomic.
522
+ if (retiredTables.length > 0) {
523
+ await this.#db.batch(
524
+ retiredTables.flatMap((tableName) => [
525
+ this.#db
526
+ .prepare('DELETE FROM sync_row_scopes WHERE tbl=?')
527
+ .bind(tableName),
528
+ this.#db.prepare(dropTableDdl(tableName)),
529
+ ]),
530
+ );
531
+ }
515
532
  await this.#db
516
533
  .prepare(
517
534
  'INSERT INTO sync_schema_meta(id, schema_version, layouts) VALUES (1, ?, ?) ON CONFLICT(id) DO UPDATE SET schema_version=excluded.schema_version, layouts=excluded.layouts',
@@ -46,9 +46,11 @@ import {
46
46
  import {
47
47
  commitWindowPageSql,
48
48
  deleteRowSql,
49
+ dropTableDdl,
49
50
  layoutsOf,
50
51
  migratePayload,
51
52
  parseLayouts,
53
+ retiredTableNames,
52
54
  rewritePlan,
53
55
  rewriteRowSql,
54
56
  rewriteValues,
@@ -721,6 +723,7 @@ export class PostgresServerStorage implements ServerStorage {
721
723
  ? marker.rows[0].layouts
722
724
  : undefined,
723
725
  );
726
+ const retiredTables = retiredTableNames(schema, layouts);
724
727
  await this.#exec.transaction(async (client) => {
725
728
  const existing = new Map<string, ReadonlySet<string>>();
726
729
  for (const table of schema.tables.values()) {
@@ -733,6 +736,12 @@ export class PostgresServerStorage implements ServerStorage {
733
736
  existing.set(table.name, new Set(rows.map((r) => r.column_name)));
734
737
  }
735
738
  }
739
+ for (const tableName of retiredTables) {
740
+ await client.query('DELETE FROM sync_row_scopes WHERE tbl=$1', [
741
+ tableName,
742
+ ]);
743
+ await client.query(dropTableDdl(tableName));
744
+ }
736
745
  for (const statement of schemaDdl(schema, existing, 'postgres')) {
737
746
  await client.query(statement);
738
747
  }
@@ -453,6 +453,25 @@ export function parseLayouts(json: string | null | undefined): StoredLayouts {
453
453
  return JSON.parse(json) as StoredLayouts;
454
454
  }
455
455
 
456
+ /**
457
+ * Tables present in the stored layout but absent from the configured head
458
+ * schema. A schema-version bump retires their relational current-row tables;
459
+ * append-only commit history remains governed by the normal retention policy.
460
+ */
461
+ export function retiredTableNames(
462
+ schema: CompiledSchema,
463
+ storedLayouts: StoredLayouts,
464
+ ): string[] {
465
+ return Object.keys(storedLayouts)
466
+ .filter((name) => !schema.tables.has(name))
467
+ .sort();
468
+ }
469
+
470
+ /** Idempotent DDL for one retired relational current-row table. */
471
+ export function dropTableDdl(tableName: string): string {
472
+ return `DROP TABLE IF EXISTS ${quoteIdent(tableName)}`;
473
+ }
474
+
456
475
  /**
457
476
  * Enforce the migration subset on a table's column list: the old layout
458
477
  * must be an exact prefix (same name, type, nullability) of the new one,
@@ -11,9 +11,11 @@ import { syncError } from './errors';
11
11
  import {
12
12
  commitWindowPageSql,
13
13
  deleteRowSql,
14
+ dropTableDdl,
14
15
  layoutsOf,
15
16
  migratePayload,
16
17
  parseLayouts,
18
+ retiredTableNames,
17
19
  rewritePlan,
18
20
  rewriteRowSql,
19
21
  rewriteValues,
@@ -273,6 +275,7 @@ export class SqliteServerStorage implements ServerStorage {
273
275
  // projection backfill for flipped-on materialization). One
274
276
  // transaction: a failed bump leaves no half-state.
275
277
  const layouts = parseLayouts(marker?.layouts);
278
+ const retiredTables = retiredTableNames(schema, layouts);
276
279
  const existing = new Map<string, ReadonlySet<string>>();
277
280
  for (const table of schema.tables.values()) {
278
281
  const columns = this.db
@@ -286,6 +289,12 @@ export class SqliteServerStorage implements ServerStorage {
286
289
  }
287
290
  this.db.exec('BEGIN IMMEDIATE');
288
291
  try {
292
+ for (const tableName of retiredTables) {
293
+ this.db
294
+ .query('DELETE FROM sync_row_scopes WHERE tbl=?')
295
+ .run(tableName);
296
+ this.db.exec(dropTableDdl(tableName));
297
+ }
289
298
  for (const statement of schemaDdl(schema, existing, 'sqlite')) {
290
299
  this.db.exec(statement);
291
300
  }