@syncular/server 0.15.10 → 0.15.12

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.
@@ -309,15 +309,23 @@ export class D1ServerStorage {
309
309
  const layouts = parseLayouts(marker?.layouts);
310
310
  const retiredTables = retiredTableNames(schema, layouts);
311
311
  const existing = new Map();
312
+ const existingIndexes = new Map();
312
313
  for (const table of schema.tables.values()) {
314
+ const escapedTableName = table.name.replaceAll('"', '""');
313
315
  const { results } = await this.#db
314
- .prepare(`PRAGMA table_info("${table.name.replaceAll('"', '""')}")`)
316
+ .prepare(`PRAGMA table_info("${escapedTableName}")`)
315
317
  .all();
316
318
  if (results.length > 0) {
317
319
  existing.set(table.name, new Set(results.map((c) => c.name)));
320
+ const indexes = await this.#db
321
+ .prepare(`PRAGMA index_list("${escapedTableName}")`)
322
+ .all();
323
+ existingIndexes.set(table.name, new Set(indexes.results
324
+ .filter((index) => index.origin === 'c')
325
+ .map((index) => index.name)));
318
326
  }
319
327
  }
320
- for (const statement of schemaDdl(schema, existing, 'sqlite')) {
328
+ for (const statement of schemaDdl(schema, existing, 'sqlite', existingIndexes)) {
321
329
  await this.#db.exec(`${statement.replace(/\s+/g, ' ')};`);
322
330
  }
323
331
  // Migration rewrite: payload re-encode on layout change and/or
@@ -449,7 +449,7 @@ export class PostgresServerStorage {
449
449
  }
450
450
  if (stored === undefined || stored < schema.version) {
451
451
  // Introspect existing app tables, apply the migration subset
452
- // (CREATE TABLE / ADD COLUMN / CREATE INDEX), then rewrite stored
452
+ // (CREATE TABLE / ADD COLUMN / rebuild indexes), then rewrite stored
453
453
  // rows (payload re-encode for layout changes and/or projection
454
454
  // backfill for flipped-on materialization) — all inside one
455
455
  // transaction (Postgres DDL is transactional — a failed bump leaves
@@ -460,11 +460,24 @@ export class PostgresServerStorage {
460
460
  const retiredTables = retiredTableNames(schema, layouts);
461
461
  await this.#exec.transaction(async (client) => {
462
462
  const existing = new Map();
463
+ const existingIndexes = new Map();
463
464
  for (const table of schema.tables.values()) {
464
465
  const { rows } = await client.query(`SELECT column_name FROM information_schema.columns
465
466
  WHERE table_schema = current_schema() AND table_name = $1`, [table.name]);
466
467
  if (rows.length > 0) {
467
468
  existing.set(table.name, new Set(rows.map((r) => r.column_name)));
469
+ const indexes = await client.query(`SELECT index_class.relname AS index_name
470
+ FROM pg_catalog.pg_class AS table_class
471
+ JOIN pg_catalog.pg_namespace AS namespace
472
+ ON namespace.oid = table_class.relnamespace
473
+ JOIN pg_catalog.pg_index AS index_meta
474
+ ON index_meta.indrelid = table_class.oid
475
+ JOIN pg_catalog.pg_class AS index_class
476
+ ON index_class.oid = index_meta.indexrelid
477
+ WHERE namespace.nspname = current_schema()
478
+ AND table_class.relname = $1
479
+ AND NOT index_meta.indisprimary`, [table.name]);
480
+ existingIndexes.set(table.name, new Set(indexes.rows.map((index) => index.index_name)));
468
481
  }
469
482
  }
470
483
  for (const tableName of retiredTables) {
@@ -473,7 +486,7 @@ export class PostgresServerStorage {
473
486
  ]);
474
487
  await client.query(dropTableDdl(tableName));
475
488
  }
476
- for (const statement of schemaDdl(schema, existing, 'postgres')) {
489
+ for (const statement of schemaDdl(schema, existing, 'postgres', existingIndexes)) {
477
490
  await client.query(statement);
478
491
  }
479
492
  for (const table of schema.tables.values()) {
@@ -76,6 +76,8 @@ export declare function addColumnDdl(table: CompiledTable, existingColumns: Read
76
76
  * as it is client-side).
77
77
  */
78
78
  export declare function createIndexDdl(table: CompiledTable): string[];
79
+ /** Idempotent removal of one Syncular-owned relational projection index. */
80
+ export declare function dropIndexDdl(indexName: string): string;
79
81
  /**
80
82
  * Convert one decoded row value to its SQL bind value.
81
83
  * - boolean → 0/1 on sqlite (INTEGER affinity), native on postgres;
@@ -229,9 +231,10 @@ export declare function rewritePlan(table: CompiledTable, oldLayout: readonly St
229
231
  backfill: boolean;
230
232
  };
231
233
  /**
232
- * Every DDL statement to bring a database from `existingColumnsByTable`
233
- * (introspected; a missing key = table absent) to `schema`: CREATE TABLE
234
- * for absent tables, ADD COLUMN for missing columns, CREATE INDEX for the
235
- * declared user indexes exactly the migration subset.
234
+ * Every DDL statement to bring a database from the introspected relational
235
+ * projections to `schema`: CREATE TABLE for absent tables, ADD COLUMN for
236
+ * missing columns, and rebuild declared secondary indexes. Rebuilding during
237
+ * a version bump supports DROP INDEX and same-name index replacement without
238
+ * requiring historical index definitions in the stored column-layout marker.
236
239
  */
237
- export declare function schemaDdl(schema: CompiledSchema, existingColumnsByTable: ReadonlyMap<string, ReadonlySet<string>>, dialect: RelationalDialect): string[];
240
+ export declare function schemaDdl(schema: CompiledSchema, existingColumnsByTable: ReadonlyMap<string, ReadonlySet<string>>, dialect: RelationalDialect, existingIndexesByTable?: ReadonlyMap<string, ReadonlySet<string>>): string[];
@@ -162,6 +162,10 @@ export function createIndexDdl(table) {
162
162
  return `CREATE ${unique}INDEX IF NOT EXISTS ${quoteIdent(index.name)} ON ${quoteIdent(table.name)} (${columns})`;
163
163
  });
164
164
  }
165
+ /** Idempotent removal of one Syncular-owned relational projection index. */
166
+ export function dropIndexDdl(indexName) {
167
+ return `DROP INDEX IF EXISTS ${quoteIdent(indexName)}`;
168
+ }
165
169
  /**
166
170
  * Convert one decoded row value to its SQL bind value.
167
171
  * - boolean → 0/1 on sqlite (INTEGER affinity), native on postgres;
@@ -399,7 +403,7 @@ export function dropTableDdl(tableName) {
399
403
  */
400
404
  export function assertAppendOnlyMigration(tableName, oldLayout, table) {
401
405
  if (oldLayout.length > table.columns.length) {
402
- throw new Error(`table ${JSON.stringify(tableName)}: the schema removed columns — only CREATE TABLE / ADD COLUMN / CREATE INDEX migrations are supported`);
406
+ throw new Error(`table ${JSON.stringify(tableName)}: the schema removed columns — only appending nullable columns is supported; indexes may be created, replaced, or dropped independently`);
403
407
  }
404
408
  for (let i = 0; i < oldLayout.length; i++) {
405
409
  const before = oldLayout[i];
@@ -503,12 +507,13 @@ export function rewritePlan(table, oldLayout, physicalColumnsBefore) {
503
507
  return { migrate, backfill };
504
508
  }
505
509
  /**
506
- * Every DDL statement to bring a database from `existingColumnsByTable`
507
- * (introspected; a missing key = table absent) to `schema`: CREATE TABLE
508
- * for absent tables, ADD COLUMN for missing columns, CREATE INDEX for the
509
- * declared user indexes exactly the migration subset.
510
+ * Every DDL statement to bring a database from the introspected relational
511
+ * projections to `schema`: CREATE TABLE for absent tables, ADD COLUMN for
512
+ * missing columns, and rebuild declared secondary indexes. Rebuilding during
513
+ * a version bump supports DROP INDEX and same-name index replacement without
514
+ * requiring historical index definitions in the stored column-layout marker.
510
515
  */
511
- export function schemaDdl(schema, existingColumnsByTable, dialect) {
516
+ export function schemaDdl(schema, existingColumnsByTable, dialect, existingIndexesByTable = new Map()) {
512
517
  const out = [];
513
518
  for (const table of schema.tables.values()) {
514
519
  const existing = existingColumnsByTable.get(table.name);
@@ -516,6 +521,9 @@ export function schemaDdl(schema, existingColumnsByTable, dialect) {
516
521
  out.push(createTableDdl(table, dialect));
517
522
  }
518
523
  else {
524
+ for (const indexName of existingIndexesByTable.get(table.name) ?? []) {
525
+ out.push(dropIndexDdl(indexName));
526
+ }
519
527
  out.push(...addColumnDdl(table, existing, dialect));
520
528
  }
521
529
  out.push(...createIndexDdl(table));
@@ -138,19 +138,26 @@ export class SqliteServerStorage {
138
138
  }
139
139
  if (marker === null || marker.schema_version < schema.version) {
140
140
  // Introspect existing app tables, then apply the migration subset
141
- // (CREATE TABLE / ADD COLUMN / CREATE INDEX) to reach `schema`, then
141
+ // (CREATE TABLE / ADD COLUMN / rebuild indexes) to reach `schema`, then
142
142
  // rewrite stored rows (payload re-encode for layout changes, and/or
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
146
  const retiredTables = retiredTableNames(schema, layouts);
147
147
  const existing = new Map();
148
+ const existingIndexes = new Map();
148
149
  for (const table of schema.tables.values()) {
150
+ const escapedTableName = table.name.replaceAll('"', '""');
149
151
  const columns = this.db
150
- .query(`PRAGMA table_info("${table.name.replaceAll('"', '""')}")`)
152
+ .query(`PRAGMA table_info("${escapedTableName}")`)
151
153
  .all();
152
154
  if (columns.length > 0) {
153
155
  existing.set(table.name, new Set(columns.map((c) => c.name)));
156
+ const indexes = this.db
157
+ .query(`PRAGMA index_list("${escapedTableName}")`)
158
+ .all()
159
+ .filter((index) => index.origin === 'c');
160
+ existingIndexes.set(table.name, new Set(indexes.map((index) => index.name)));
154
161
  }
155
162
  }
156
163
  this.db.exec('BEGIN IMMEDIATE');
@@ -161,7 +168,7 @@ export class SqliteServerStorage {
161
168
  .run(tableName);
162
169
  this.db.exec(dropTableDdl(tableName));
163
170
  }
164
- for (const statement of schemaDdl(schema, existing, 'sqlite')) {
171
+ for (const statement of schemaDdl(schema, existing, 'sqlite', existingIndexes)) {
165
172
  this.db.exec(statement);
166
173
  }
167
174
  for (const table of schema.tables.values()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/server",
3
- "version": "0.15.10",
3
+ "version": "0.15.12",
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.15.10"
56
+ "@syncular/core": "0.15.12"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@electric-sql/pglite": "^0.5.4"
package/src/d1-storage.ts CHANGED
@@ -493,15 +493,33 @@ export class D1ServerStorage implements ServerStorage {
493
493
  const layouts = parseLayouts(marker?.layouts);
494
494
  const retiredTables = retiredTableNames(schema, layouts);
495
495
  const existing = new Map<string, ReadonlySet<string>>();
496
+ const existingIndexes = new Map<string, ReadonlySet<string>>();
496
497
  for (const table of schema.tables.values()) {
498
+ const escapedTableName = table.name.replaceAll('"', '""');
497
499
  const { results } = await this.#db
498
- .prepare(`PRAGMA table_info("${table.name.replaceAll('"', '""')}")`)
500
+ .prepare(`PRAGMA table_info("${escapedTableName}")`)
499
501
  .all<{ name: string }>();
500
502
  if (results.length > 0) {
501
503
  existing.set(table.name, new Set(results.map((c) => c.name)));
504
+ const indexes = await this.#db
505
+ .prepare(`PRAGMA index_list("${escapedTableName}")`)
506
+ .all<{ name: string; origin: string }>();
507
+ existingIndexes.set(
508
+ table.name,
509
+ new Set(
510
+ indexes.results
511
+ .filter((index) => index.origin === 'c')
512
+ .map((index) => index.name),
513
+ ),
514
+ );
502
515
  }
503
516
  }
504
- for (const statement of schemaDdl(schema, existing, 'sqlite')) {
517
+ for (const statement of schemaDdl(
518
+ schema,
519
+ existing,
520
+ 'sqlite',
521
+ existingIndexes,
522
+ )) {
505
523
  await this.#db.exec(`${statement.replace(/\s+/g, ' ')};`);
506
524
  }
507
525
  // Migration rewrite: payload re-encode on layout change and/or
@@ -713,7 +713,7 @@ export class PostgresServerStorage implements ServerStorage {
713
713
  }
714
714
  if (stored === undefined || stored < schema.version) {
715
715
  // Introspect existing app tables, apply the migration subset
716
- // (CREATE TABLE / ADD COLUMN / CREATE INDEX), then rewrite stored
716
+ // (CREATE TABLE / ADD COLUMN / rebuild indexes), then rewrite stored
717
717
  // rows (payload re-encode for layout changes and/or projection
718
718
  // backfill for flipped-on materialization) — all inside one
719
719
  // transaction (Postgres DDL is transactional — a failed bump leaves
@@ -726,6 +726,7 @@ export class PostgresServerStorage implements ServerStorage {
726
726
  const retiredTables = retiredTableNames(schema, layouts);
727
727
  await this.#exec.transaction(async (client) => {
728
728
  const existing = new Map<string, ReadonlySet<string>>();
729
+ const existingIndexes = new Map<string, ReadonlySet<string>>();
729
730
  for (const table of schema.tables.values()) {
730
731
  const { rows } = await client.query<{ column_name: string }>(
731
732
  `SELECT column_name FROM information_schema.columns
@@ -734,6 +735,24 @@ export class PostgresServerStorage implements ServerStorage {
734
735
  );
735
736
  if (rows.length > 0) {
736
737
  existing.set(table.name, new Set(rows.map((r) => r.column_name)));
738
+ const indexes = await client.query<{ index_name: string }>(
739
+ `SELECT index_class.relname AS index_name
740
+ FROM pg_catalog.pg_class AS table_class
741
+ JOIN pg_catalog.pg_namespace AS namespace
742
+ ON namespace.oid = table_class.relnamespace
743
+ JOIN pg_catalog.pg_index AS index_meta
744
+ ON index_meta.indrelid = table_class.oid
745
+ JOIN pg_catalog.pg_class AS index_class
746
+ ON index_class.oid = index_meta.indexrelid
747
+ WHERE namespace.nspname = current_schema()
748
+ AND table_class.relname = $1
749
+ AND NOT index_meta.indisprimary`,
750
+ [table.name],
751
+ );
752
+ existingIndexes.set(
753
+ table.name,
754
+ new Set(indexes.rows.map((index) => index.index_name)),
755
+ );
737
756
  }
738
757
  }
739
758
  for (const tableName of retiredTables) {
@@ -742,7 +761,12 @@ export class PostgresServerStorage implements ServerStorage {
742
761
  ]);
743
762
  await client.query(dropTableDdl(tableName));
744
763
  }
745
- for (const statement of schemaDdl(schema, existing, 'postgres')) {
764
+ for (const statement of schemaDdl(
765
+ schema,
766
+ existing,
767
+ 'postgres',
768
+ existingIndexes,
769
+ )) {
746
770
  await client.query(statement);
747
771
  }
748
772
  for (const table of schema.tables.values()) {
@@ -188,6 +188,11 @@ export function createIndexDdl(table: CompiledTable): string[] {
188
188
  });
189
189
  }
190
190
 
191
+ /** Idempotent removal of one Syncular-owned relational projection index. */
192
+ export function dropIndexDdl(indexName: string): string {
193
+ return `DROP INDEX IF EXISTS ${quoteIdent(indexName)}`;
194
+ }
195
+
191
196
  /**
192
197
  * Convert one decoded row value to its SQL bind value.
193
198
  * - boolean → 0/1 on sqlite (INTEGER affinity), native on postgres;
@@ -481,7 +486,7 @@ export function assertAppendOnlyMigration(
481
486
  ): boolean {
482
487
  if (oldLayout.length > table.columns.length) {
483
488
  throw new Error(
484
- `table ${JSON.stringify(tableName)}: the schema removed columns — only CREATE TABLE / ADD COLUMN / CREATE INDEX migrations are supported`,
489
+ `table ${JSON.stringify(tableName)}: the schema removed columns — only appending nullable columns is supported; indexes may be created, replaced, or dropped independently`,
485
490
  );
486
491
  }
487
492
  for (let i = 0; i < oldLayout.length; i++) {
@@ -624,15 +629,17 @@ export function rewritePlan(
624
629
  }
625
630
 
626
631
  /**
627
- * Every DDL statement to bring a database from `existingColumnsByTable`
628
- * (introspected; a missing key = table absent) to `schema`: CREATE TABLE
629
- * for absent tables, ADD COLUMN for missing columns, CREATE INDEX for the
630
- * declared user indexes exactly the migration subset.
632
+ * Every DDL statement to bring a database from the introspected relational
633
+ * projections to `schema`: CREATE TABLE for absent tables, ADD COLUMN for
634
+ * missing columns, and rebuild declared secondary indexes. Rebuilding during
635
+ * a version bump supports DROP INDEX and same-name index replacement without
636
+ * requiring historical index definitions in the stored column-layout marker.
631
637
  */
632
638
  export function schemaDdl(
633
639
  schema: CompiledSchema,
634
640
  existingColumnsByTable: ReadonlyMap<string, ReadonlySet<string>>,
635
641
  dialect: RelationalDialect,
642
+ existingIndexesByTable: ReadonlyMap<string, ReadonlySet<string>> = new Map(),
636
643
  ): string[] {
637
644
  const out: string[] = [];
638
645
  for (const table of schema.tables.values()) {
@@ -640,6 +647,9 @@ export function schemaDdl(
640
647
  if (existing === undefined) {
641
648
  out.push(createTableDdl(table, dialect));
642
649
  } else {
650
+ for (const indexName of existingIndexesByTable.get(table.name) ?? []) {
651
+ out.push(dropIndexDdl(indexName));
652
+ }
643
653
  out.push(...addColumnDdl(table, existing, dialect));
644
654
  }
645
655
  out.push(...createIndexDdl(table));
@@ -270,21 +270,33 @@ export class SqliteServerStorage implements ServerStorage {
270
270
  }
271
271
  if (marker === null || marker.schema_version < schema.version) {
272
272
  // Introspect existing app tables, then apply the migration subset
273
- // (CREATE TABLE / ADD COLUMN / CREATE INDEX) to reach `schema`, then
273
+ // (CREATE TABLE / ADD COLUMN / rebuild indexes) to reach `schema`, then
274
274
  // rewrite stored rows (payload re-encode for layout changes, and/or
275
275
  // projection backfill for flipped-on materialization). One
276
276
  // transaction: a failed bump leaves no half-state.
277
277
  const layouts = parseLayouts(marker?.layouts);
278
278
  const retiredTables = retiredTableNames(schema, layouts);
279
279
  const existing = new Map<string, ReadonlySet<string>>();
280
+ const existingIndexes = new Map<string, ReadonlySet<string>>();
280
281
  for (const table of schema.tables.values()) {
282
+ const escapedTableName = table.name.replaceAll('"', '""');
281
283
  const columns = this.db
282
284
  .query<{ name: string }, []>(
283
- `PRAGMA table_info("${table.name.replaceAll('"', '""')}")`,
285
+ `PRAGMA table_info("${escapedTableName}")`,
284
286
  )
285
287
  .all();
286
288
  if (columns.length > 0) {
287
289
  existing.set(table.name, new Set(columns.map((c) => c.name)));
290
+ const indexes = this.db
291
+ .query<{ name: string; origin: string }, []>(
292
+ `PRAGMA index_list("${escapedTableName}")`,
293
+ )
294
+ .all()
295
+ .filter((index) => index.origin === 'c');
296
+ existingIndexes.set(
297
+ table.name,
298
+ new Set(indexes.map((index) => index.name)),
299
+ );
288
300
  }
289
301
  }
290
302
  this.db.exec('BEGIN IMMEDIATE');
@@ -295,7 +307,12 @@ export class SqliteServerStorage implements ServerStorage {
295
307
  .run(tableName);
296
308
  this.db.exec(dropTableDdl(tableName));
297
309
  }
298
- for (const statement of schemaDdl(schema, existing, 'sqlite')) {
310
+ for (const statement of schemaDdl(
311
+ schema,
312
+ existing,
313
+ 'sqlite',
314
+ existingIndexes,
315
+ )) {
299
316
  this.db.exec(statement);
300
317
  }
301
318
  for (const table of schema.tables.values()) {