@lenne.tech/nest-server 12.1.1 → 12.2.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.
Files changed (191) hide show
  1. package/.claude/rules/architecture.md +1 -0
  2. package/.claude/rules/configurable-features.md +1 -0
  3. package/.claude/rules/package-management.md +16 -1
  4. package/.claude/rules/persistence.md +26 -0
  5. package/.claude/rules/testing.md +79 -14
  6. package/CLAUDE.md +2 -1
  7. package/FRAMEWORK-API.md +17 -2
  8. package/README.md +18 -0
  9. package/dist/core/common/interfaces/server-options.interface.d.ts +41 -0
  10. package/dist/core/common/interfaces/service-options.interface.d.ts +3 -0
  11. package/dist/core/common/persistence/core-persistence.module.js +12 -2
  12. package/dist/core/common/persistence/core-persistence.module.js.map +1 -1
  13. package/dist/core/common/persistence/data-source.runtime.js +4 -2
  14. package/dist/core/common/persistence/data-source.runtime.js.map +1 -1
  15. package/dist/core/common/persistence/data-store.interface.d.ts +24 -9
  16. package/dist/core/common/persistence/id-format.d.ts +2 -0
  17. package/dist/core/common/persistence/id-format.js +14 -0
  18. package/dist/core/common/persistence/id-format.js.map +1 -1
  19. package/dist/core/common/persistence/model-descriptor.interface.d.ts +6 -0
  20. package/dist/core/common/persistence/model-metadata.registry.d.ts +2 -1
  21. package/dist/core/common/persistence/model-metadata.registry.js +20 -2
  22. package/dist/core/common/persistence/model-metadata.registry.js.map +1 -1
  23. package/dist/core/common/persistence/mongo/sync-schema.plugin.d.ts +18 -0
  24. package/dist/core/common/persistence/mongo/sync-schema.plugin.js +368 -0
  25. package/dist/core/common/persistence/mongo/sync-schema.plugin.js.map +1 -0
  26. package/dist/core/common/persistence/mongoose-data-store.d.ts +19 -9
  27. package/dist/core/common/persistence/mongoose-data-store.js +107 -31
  28. package/dist/core/common/persistence/mongoose-data-store.js.map +1 -1
  29. package/dist/core/common/persistence/persisted-model.decorator.d.ts +3 -0
  30. package/dist/core/common/persistence/persisted-model.decorator.js +9 -0
  31. package/dist/core/common/persistence/persisted-model.decorator.js.map +1 -1
  32. package/dist/core/common/persistence/policies/sync.policy.d.ts +17 -0
  33. package/dist/core/common/persistence/policies/sync.policy.js +77 -0
  34. package/dist/core/common/persistence/policies/sync.policy.js.map +1 -0
  35. package/dist/core/common/persistence/sql/mysql/mysql-data-store.d.ts +12 -8
  36. package/dist/core/common/persistence/sql/mysql/mysql-data-store.js +82 -29
  37. package/dist/core/common/persistence/sql/mysql/mysql-data-store.js.map +1 -1
  38. package/dist/core/common/persistence/sql/mysql/mysql-schema.compiler.d.ts +10 -0
  39. package/dist/core/common/persistence/sql/mysql/mysql-schema.compiler.js +61 -4
  40. package/dist/core/common/persistence/sql/mysql/mysql-schema.compiler.js.map +1 -1
  41. package/dist/core/common/persistence/sql/mysql/mysql-schema.migrator.js +101 -4
  42. package/dist/core/common/persistence/sql/mysql/mysql-schema.migrator.js.map +1 -1
  43. package/dist/core/common/persistence/sql/postgres/postgres-schema.compiler.d.ts +10 -0
  44. package/dist/core/common/persistence/sql/postgres/postgres-schema.compiler.js +41 -2
  45. package/dist/core/common/persistence/sql/postgres/postgres-schema.compiler.js.map +1 -1
  46. package/dist/core/common/persistence/sql/postgres/postgres-schema.migrator.js +98 -10
  47. package/dist/core/common/persistence/sql/postgres/postgres-schema.migrator.js.map +1 -1
  48. package/dist/core/common/persistence/sql/sql-data-store.d.ts +38 -14
  49. package/dist/core/common/persistence/sql/sql-data-store.js +212 -38
  50. package/dist/core/common/persistence/sql/sql-data-store.js.map +1 -1
  51. package/dist/core/common/persistence/sql/sql-errors.d.ts +2 -1
  52. package/dist/core/common/persistence/sql/sql-errors.js +3 -1
  53. package/dist/core/common/persistence/sql/sql-errors.js.map +1 -1
  54. package/dist/core/common/persistence/sync-access.registry.d.ts +17 -0
  55. package/dist/core/common/persistence/sync-access.registry.js +8 -0
  56. package/dist/core/common/persistence/sync-access.registry.js.map +1 -0
  57. package/dist/core/common/persistence/sync-model.d.ts +29 -0
  58. package/dist/core/common/persistence/sync-model.js +138 -0
  59. package/dist/core/common/persistence/sync-model.js.map +1 -0
  60. package/dist/core/common/services/crud.service.d.ts +13 -1
  61. package/dist/core/common/services/crud.service.js +174 -16
  62. package/dist/core/common/services/crud.service.js.map +1 -1
  63. package/dist/core/modules/error-code/error-codes.d.ts +54 -0
  64. package/dist/core/modules/error-code/error-codes.js +48 -0
  65. package/dist/core/modules/error-code/error-codes.js.map +1 -1
  66. package/dist/core/modules/migrate/sql-migration.helper.d.ts +15 -1
  67. package/dist/core/modules/migrate/sql-migration.helper.js +90 -5
  68. package/dist/core/modules/migrate/sql-migration.helper.js.map +1 -1
  69. package/dist/core/modules/sync/core-sync-base.controller.d.ts +7 -0
  70. package/dist/core/modules/sync/core-sync-base.controller.js +44 -0
  71. package/dist/core/modules/sync/core-sync-base.controller.js.map +1 -0
  72. package/dist/core/modules/sync/core-sync-cursor.d.ts +19 -0
  73. package/dist/core/modules/sync/core-sync-cursor.js +77 -0
  74. package/dist/core/modules/sync/core-sync-cursor.js.map +1 -0
  75. package/dist/core/modules/sync/core-sync-hints.controller.d.ts +4 -0
  76. package/dist/core/modules/sync/core-sync-hints.controller.js +55 -0
  77. package/dist/core/modules/sync/core-sync-hints.controller.js.map +1 -0
  78. package/dist/core/modules/sync/core-sync-hints.service.d.ts +28 -0
  79. package/dist/core/modules/sync/core-sync-hints.service.js +155 -0
  80. package/dist/core/modules/sync/core-sync-hints.service.js.map +1 -0
  81. package/dist/core/modules/sync/core-sync-infrastructure.models.d.ts +2 -0
  82. package/dist/core/modules/sync/core-sync-infrastructure.models.js +17 -0
  83. package/dist/core/modules/sync/core-sync-infrastructure.models.js.map +1 -0
  84. package/dist/core/modules/sync/core-sync-mutation.store.d.ts +43 -0
  85. package/dist/core/modules/sync/core-sync-mutation.store.js +88 -0
  86. package/dist/core/modules/sync/core-sync-mutation.store.js.map +1 -0
  87. package/dist/core/modules/sync/core-sync-state.store.d.ts +18 -0
  88. package/dist/core/modules/sync/core-sync-state.store.js +64 -0
  89. package/dist/core/modules/sync/core-sync-state.store.js.map +1 -0
  90. package/dist/core/modules/sync/core-sync.constants.d.ts +28 -0
  91. package/dist/core/modules/sync/core-sync.constants.js +23 -0
  92. package/dist/core/modules/sync/core-sync.constants.js.map +1 -0
  93. package/dist/core/modules/sync/core-sync.controller.d.ts +8 -0
  94. package/dist/core/modules/sync/core-sync.controller.js +79 -0
  95. package/dist/core/modules/sync/core-sync.controller.js.map +1 -0
  96. package/dist/core/modules/sync/core-sync.decorators.d.ts +12 -0
  97. package/dist/core/modules/sync/core-sync.decorators.js +28 -0
  98. package/dist/core/modules/sync/core-sync.decorators.js.map +1 -0
  99. package/dist/core/modules/sync/core-sync.module.d.ts +16 -0
  100. package/dist/core/modules/sync/core-sync.module.js +50 -0
  101. package/dist/core/modules/sync/core-sync.module.js.map +1 -0
  102. package/dist/core/modules/sync/core-sync.resolver.d.ts +9 -0
  103. package/dist/core/modules/sync/core-sync.resolver.js +46 -0
  104. package/dist/core/modules/sync/core-sync.resolver.js.map +1 -0
  105. package/dist/core/modules/sync/core-sync.service.d.ts +142 -0
  106. package/dist/core/modules/sync/core-sync.service.js +1208 -0
  107. package/dist/core/modules/sync/core-sync.service.js.map +1 -0
  108. package/dist/core/modules/sync/interfaces/core-sync.interfaces.d.ts +56 -0
  109. package/dist/core/modules/sync/interfaces/core-sync.interfaces.js +2 -0
  110. package/dist/core/modules/sync/interfaces/core-sync.interfaces.js.map +1 -0
  111. package/dist/core/modules/sync/models/core-sync-hint.model.d.ts +4 -0
  112. package/dist/core/modules/sync/models/core-sync-hint.model.js +30 -0
  113. package/dist/core/modules/sync/models/core-sync-hint.model.js.map +1 -0
  114. package/dist/core/modules/tenant/core-tenant.guard.js +4 -0
  115. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
  116. package/dist/core.module.js +14 -0
  117. package/dist/core.module.js.map +1 -1
  118. package/dist/index.d.ts +16 -0
  119. package/dist/index.js +16 -0
  120. package/dist/index.js.map +1 -1
  121. package/dist/server/modules/error-code/error-codes.d.ts +6 -0
  122. package/dist/server/modules/sync-note/inputs/sync-note-create.input.d.ts +4 -0
  123. package/dist/server/modules/sync-note/inputs/sync-note-create.input.js +27 -0
  124. package/dist/server/modules/sync-note/inputs/sync-note-create.input.js.map +1 -0
  125. package/dist/server/modules/sync-note/inputs/sync-note.input.d.ts +6 -0
  126. package/dist/server/modules/sync-note/inputs/sync-note.input.js +37 -0
  127. package/dist/server/modules/sync-note/inputs/sync-note.input.js.map +1 -0
  128. package/dist/server/modules/sync-note/sync-note.model.d.ts +10 -0
  129. package/dist/server/modules/sync-note/sync-note.model.js +73 -0
  130. package/dist/server/modules/sync-note/sync-note.model.js.map +1 -0
  131. package/dist/server/modules/sync-note/sync-note.module.d.ts +2 -0
  132. package/dist/server/modules/sync-note/sync-note.module.js +21 -0
  133. package/dist/server/modules/sync-note/sync-note.module.js.map +1 -0
  134. package/dist/server/modules/sync-note/sync-note.service.d.ts +10 -0
  135. package/dist/server/modules/sync-note/sync-note.service.js +35 -0
  136. package/dist/server/modules/sync-note/sync-note.service.js.map +1 -0
  137. package/docs/REQUEST-LIFECYCLE.md +1 -0
  138. package/docs/offline-sync-protocol.md +96 -62
  139. package/docs/security-overrides.md +14 -0
  140. package/migration-guides/12.1.x-to-12.2.x.md +154 -0
  141. package/package.json +4 -2
  142. package/src/core/common/interfaces/server-options.interface.ts +149 -0
  143. package/src/core/common/interfaces/service-options.interface.ts +15 -0
  144. package/src/core/common/persistence/core-persistence.module.ts +21 -2
  145. package/src/core/common/persistence/data-source.runtime.ts +5 -2
  146. package/src/core/common/persistence/data-store.interface.ts +65 -12
  147. package/src/core/common/persistence/id-format.ts +26 -0
  148. package/src/core/common/persistence/model-descriptor.interface.ts +12 -0
  149. package/src/core/common/persistence/model-metadata.registry.ts +31 -2
  150. package/src/core/common/persistence/mongo/sync-schema.plugin.ts +508 -0
  151. package/src/core/common/persistence/mongoose-data-store.ts +171 -35
  152. package/src/core/common/persistence/persisted-model.decorator.ts +26 -0
  153. package/src/core/common/persistence/policies/sync.policy.ts +121 -0
  154. package/src/core/common/persistence/sql/mysql/mysql-data-store.ts +108 -30
  155. package/src/core/common/persistence/sql/mysql/mysql-schema.compiler.ts +107 -4
  156. package/src/core/common/persistence/sql/mysql/mysql-schema.migrator.ts +129 -3
  157. package/src/core/common/persistence/sql/postgres/postgres-schema.compiler.ts +74 -2
  158. package/src/core/common/persistence/sql/postgres/postgres-schema.migrator.ts +139 -7
  159. package/src/core/common/persistence/sql/sql-data-store.ts +325 -44
  160. package/src/core/common/persistence/sql/sql-errors.ts +5 -1
  161. package/src/core/common/persistence/sync-access.registry.ts +50 -0
  162. package/src/core/common/persistence/sync-model.ts +270 -0
  163. package/src/core/common/services/crud.service.ts +257 -19
  164. package/src/core/modules/error-code/error-codes.ts +59 -0
  165. package/src/core/modules/migrate/sql-migration.helper.ts +141 -7
  166. package/src/core/modules/sync/INTEGRATION-CHECKLIST.md +95 -0
  167. package/src/core/modules/sync/README.md +188 -0
  168. package/src/core/modules/sync/SECURITY.md +65 -0
  169. package/src/core/modules/sync/core-sync-base.controller.ts +43 -0
  170. package/src/core/modules/sync/core-sync-cursor.ts +117 -0
  171. package/src/core/modules/sync/core-sync-hints.controller.ts +52 -0
  172. package/src/core/modules/sync/core-sync-hints.service.ts +178 -0
  173. package/src/core/modules/sync/core-sync-infrastructure.models.ts +40 -0
  174. package/src/core/modules/sync/core-sync-mutation.store.ts +146 -0
  175. package/src/core/modules/sync/core-sync-state.store.ts +89 -0
  176. package/src/core/modules/sync/core-sync.constants.ts +51 -0
  177. package/src/core/modules/sync/core-sync.controller.ts +68 -0
  178. package/src/core/modules/sync/core-sync.decorators.ts +84 -0
  179. package/src/core/modules/sync/core-sync.module.ts +75 -0
  180. package/src/core/modules/sync/core-sync.resolver.ts +35 -0
  181. package/src/core/modules/sync/core-sync.service.ts +1728 -0
  182. package/src/core/modules/sync/interfaces/core-sync.interfaces.ts +85 -0
  183. package/src/core/modules/sync/models/core-sync-hint.model.ts +18 -0
  184. package/src/core/modules/tenant/core-tenant.guard.ts +8 -0
  185. package/src/core.module.ts +25 -0
  186. package/src/index.ts +21 -0
  187. package/src/server/modules/sync-note/inputs/sync-note-create.input.ts +14 -0
  188. package/src/server/modules/sync-note/inputs/sync-note.input.ts +23 -0
  189. package/src/server/modules/sync-note/sync-note.model.ts +77 -0
  190. package/src/server/modules/sync-note/sync-note.module.ts +16 -0
  191. package/src/server/modules/sync-note/sync-note.service.ts +25 -0
@@ -2,6 +2,7 @@ import type { FieldDescriptor, IndexDescriptor, ModelDescriptor } from '../../mo
2
2
  import { UnsupportedQueryError } from '../sql-errors.js';
3
3
  import { columnName, quoteIdentifier, SqlLiterals, sqlLiteral } from '../sql-naming.js';
4
4
  import { schemaHash, shortName, tableName } from '../sql-schema.shared.js';
5
+ import { isLiveFilter, SYNC_SEQUENCE_FIELD } from '../../sync-model.js';
5
6
  import { compilePostgresWhere } from './postgres-where.compiler.js';
6
7
 
7
8
  export { LOCK_MODEL, shortName, tableName } from '../sql-schema.shared.js';
@@ -23,6 +24,15 @@ export { LOCK_MODEL, shortName, tableName } from '../sql-schema.shared.js';
23
24
  /** Any string item that starts with `s_` after leading whitespace, case-insensitive (`looksLikeSystemRole`) */
24
25
  const SYSTEM_ROLE_PATH = '$[*] ? (@.type() == "string" && @ like_regex "^\\\\s*s_" flag "i")';
25
26
 
27
+ /**
28
+ * Bumped whenever the change-order trigger (`lt_sync_stamp`) changes, so the migrator replaces the function and
29
+ * every table's trigger — the trigger's name carries the version, because a trigger has no comment the catalog query
30
+ * could read alongside the table
31
+ */
32
+ export const SYNC_TRIGGER_VERSION = 'lt:sync:v1';
33
+ export const SYNC_STAMP_FUNCTION = 'lt_sync_stamp';
34
+ export const SYNC_TRIGGER = 'lt_sync_stamp_v1';
35
+
26
36
  /** Bumped whenever the generated guard function or policies change, so the migrator replaces them */
27
37
  export const RLS_VERSION = 'lt:tenant:v1';
28
38
  export const TENANT_FILTER_FUNCTION = 'lt_tenant_filter';
@@ -41,6 +51,12 @@ export interface DesiredColumn {
41
51
  renamedFrom?: string;
42
52
  }
43
53
 
54
+ /** The objects that number a synchronized model's changes (offline-sync plan §3.4) */
55
+ export interface DesiredSync {
56
+ /** The table's sequence: `nextval()` under the model's advisory lock gives commit order */
57
+ sequence: string;
58
+ }
59
+
44
60
  export interface DesiredIndex {
45
61
  /** The CREATE statement */
46
62
  create: string;
@@ -61,6 +77,8 @@ export interface DesiredTable {
61
77
  name: string;
62
78
  /** What the model declares but the table cannot carry (text indexes, …), reported once */
63
79
  notices: string[];
80
+ /** Present for a synchronized model: its change-order sequence and trigger */
81
+ sync?: DesiredSync;
64
82
  /** Row-level security on `tenant_id` */
65
83
  tenantIsolated: boolean;
66
84
  }
@@ -119,7 +137,13 @@ export function desiredTable(model: ModelDescriptor, options: PostgresSchemaOpti
119
137
  indexes.push(desiredIndex(model, name, { fields: { tenantId: 1 }, options: {} }) as DesiredIndex);
120
138
  }
121
139
 
122
- return { checks, columns, indexes, model, name, notices, tenantIsolated };
140
+ const sync = model.sync && model.fields[SYNC_SEQUENCE_FIELD] ? { sequence: syncSequenceName(name) } : undefined;
141
+ return { checks, columns, indexes, model, name, notices, ...(sync ? { sync } : {}), tenantIsolated };
142
+ }
143
+
144
+ /** The change-order sequence of a synchronized model's table */
145
+ export function syncSequenceName(table: string): string {
146
+ return shortName('lt_sync', table, ['seq']);
123
147
  }
124
148
 
125
149
  function desiredColumn(field: FieldDescriptor): DesiredColumn {
@@ -223,7 +247,9 @@ function desiredIndex(model: ModelDescriptor, table: string, index: IndexDescrip
223
247
  const using = gin.length
224
248
  ? ` USING gin (${gin[0].expression} jsonb_path_ops)`
225
249
  : ` (${parts.map((part) => part.sql).join(', ')})`;
226
- const nulls = unique && !conditions.length ? ' NULLS NOT DISTINCT' : '';
250
+ // A partial index admits NULL repeatedly unless told otherwise. That is harmless where the condition excludes NULL
251
+ // (sparse), but a synchronized model's live-record condition keeps MongoDB's rule: a second live NULL collides
252
+ const nulls = unique && (!conditions.length || (!sparse && isLiveFilter(partial))) ? ' NULLS NOT DISTINCT' : '';
227
253
  const where = conditions.length ? ` WHERE ${conditions.join(' AND ')}` : '';
228
254
  return {
229
255
  create: `CREATE ${unique ? 'UNIQUE ' : ''}INDEX ${quoteIdentifier(name)} ON ${quoteIdentifier(table)}${using}${nulls}${where}`,
@@ -280,6 +306,52 @@ export function grantStatement(table: string, appRole: string): string {
280
306
  return `GRANT SELECT, INSERT, UPDATE, DELETE ON ${quoteIdentifier(table)} TO ${quoteIdentifier(appRole)}`;
281
307
  }
282
308
 
309
+ /**
310
+ * The trigger function that numbers a synchronized model's writes (offline-sync plan §3.4, C1d; DEV-3900 measured
311
+ * the advisory lock against a counter row). A BEFORE ROW trigger runs after the row is locked, so a write takes its
312
+ * row first and the order last; the transaction-scoped advisory lock is released only after the commit is visible,
313
+ * so sequence order is commit order, and `nextval()` leaves no row version a long snapshot could pile up.
314
+ *
315
+ * A value the statement sets itself is kept: the store's multi-row writes lock their rows, take their numbers and set
316
+ * them explicitly in their final statement.
317
+ */
318
+ export function syncFunctionStatements(): string[] {
319
+ const fn = quoteIdentifier(SYNC_STAMP_FUNCTION);
320
+ return [
321
+ `CREATE OR REPLACE FUNCTION ${fn}() RETURNS trigger
322
+ LANGUAGE plpgsql SET search_path = pg_catalog AS $lt$
323
+ BEGIN
324
+ IF TG_OP = 'INSERT' THEN
325
+ IF NEW.sync_seq IS NOT NULL THEN
326
+ RETURN NEW;
327
+ END IF;
328
+ ELSIF NEW.sync_seq IS DISTINCT FROM OLD.sync_seq THEN
329
+ RETURN NEW;
330
+ END IF;
331
+ PERFORM pg_advisory_xact_lock(hashtext('lenne.tech:sync:' || TG_TABLE_SCHEMA || '.' || TG_TABLE_NAME));
332
+ NEW.sync_seq := nextval(format('%I.%I', TG_TABLE_SCHEMA, TG_ARGV[0])::regclass);
333
+ RETURN NEW;
334
+ END
335
+ $lt$`,
336
+ `COMMENT ON FUNCTION ${fn}() IS ${sqlLiteral(SYNC_TRIGGER_VERSION)}`,
337
+ ];
338
+ }
339
+
340
+ /**
341
+ * A synchronized table's sequence, the application role's use of it, the trigger, and the numbering of rows written
342
+ * before the table was synchronized (in the order of their ids, i.e. of their creation)
343
+ */
344
+ export function syncTableStatements(table: string, sync: DesiredSync, appRole: string): string[] {
345
+ const quoted = quoteIdentifier(table);
346
+ const sequence = quoteIdentifier(sync.sequence);
347
+ return [
348
+ `CREATE SEQUENCE IF NOT EXISTS ${sequence}`,
349
+ `GRANT USAGE ON SEQUENCE ${sequence} TO ${quoteIdentifier(appRole)}`,
350
+ `CREATE TRIGGER ${quoteIdentifier(SYNC_TRIGGER)} BEFORE INSERT OR UPDATE ON ${quoted} FOR EACH ROW EXECUTE FUNCTION ${quoteIdentifier(SYNC_STAMP_FUNCTION)}(${sqlLiteral(sync.sequence)})`,
351
+ `UPDATE ${quoted} AS t SET "sync_seq" = n.seq FROM (SELECT "id", nextval(${sqlLiteral(sync.sequence)}::regclass) AS seq FROM (SELECT "id" FROM ${quoted} WHERE "sync_seq" IS NULL ORDER BY "id") AS o) AS n WHERE t."id" = n."id"`,
352
+ ];
353
+ }
354
+
283
355
  /**
284
356
  * The guard every tenant policy calls. It reads the scope the store wrote into the transaction (`sql-session.ts`)
285
357
  * and RAISES `42501` without one, so a query on a tenant table outside the framework's context — a forgotten scope,
@@ -18,6 +18,11 @@ import {
18
18
  grantStatement,
19
19
  type PostgresSchemaOptions,
20
20
  RLS_VERSION,
21
+ SYNC_STAMP_FUNCTION,
22
+ SYNC_TRIGGER,
23
+ SYNC_TRIGGER_VERSION,
24
+ syncFunctionStatements,
25
+ syncTableStatements,
21
26
  TENANT_FILTER_FUNCTION,
22
27
  TENANT_POLICY,
23
28
  tenantFilterFunctionStatements,
@@ -63,10 +68,15 @@ interface CatalogTable {
63
68
  granted: boolean;
64
69
  policyVersion: null | string;
65
70
  rls: boolean;
71
+ /** Change-order triggers (`lt_sync_*`) on the table */
72
+ syncTriggers: Set<string>;
66
73
  }
67
74
 
68
75
  interface Catalog {
69
76
  functionVersion: null | string;
77
+ /** Sequences of synchronized tables → whether the application role may use them */
78
+ sequences: Map<string, boolean>;
79
+ syncFunctionVersion: null | string;
70
80
  schema: string;
71
81
  /** Whether the application role may use the schema */
72
82
  schemaUsage: boolean;
@@ -124,9 +134,18 @@ export async function planPostgresSchema(
124
134
  run,
125
135
  tables.map((table) => table.name),
126
136
  options.appRole,
137
+ tables.flatMap((table) => (table.sync ? [table.sync.sequence] : [])),
127
138
  );
128
139
  const changes: SchemaChange[] = [];
129
140
 
141
+ if (tables.some((table) => table.sync) && catalog.syncFunctionVersion !== SYNC_TRIGGER_VERSION) {
142
+ changes.push({
143
+ description: `${catalog.syncFunctionVersion ? 'update' : 'create'} the change-order function ${SYNC_STAMP_FUNCTION}()`,
144
+ kind: 'automatic',
145
+ statements: syncFunctionStatements(),
146
+ });
147
+ }
148
+
130
149
  if (tables.some((table) => table.tenantIsolated) && catalog.functionVersion !== RLS_VERSION) {
131
150
  changes.push({
132
151
  description: `${catalog.functionVersion ? 'update' : 'create'} the tenant guard function ${TENANT_FILTER_FUNCTION}()`,
@@ -146,7 +165,9 @@ export async function planPostgresSchema(
146
165
  changes.push({ description: notice, kind: 'notice', statements: [], table: table.name });
147
166
  }
148
167
  const existing = catalog.tables.get(table.name);
149
- changes.push(...(existing ? planExistingTable(table, existing, options) : planNewTable(table, options)));
168
+ changes.push(
169
+ ...(existing ? planExistingTable(table, existing, catalog.sequences, options) : planNewTable(table, options)),
170
+ );
150
171
  }
151
172
  return changes;
152
173
  }
@@ -161,6 +182,7 @@ function planNewTable(table: DesiredTable, options: PostgresSchemaOptions): Sche
161
182
  ...table.indexes.map((index) => index.create),
162
183
  ...(table.tenantIsolated ? tenantPolicyStatements(table.name) : []),
163
184
  grantStatement(table.name, options.appRole),
185
+ ...(table.sync ? syncTableStatements(table.name, table.sync, options.appRole) : []),
164
186
  ],
165
187
  table: table.name,
166
188
  },
@@ -170,6 +192,7 @@ function planNewTable(table: DesiredTable, options: PostgresSchemaOptions): Sche
170
192
  function planExistingTable(
171
193
  table: DesiredTable,
172
194
  existing: CatalogTable,
195
+ sequences: Map<string, boolean>,
173
196
  options: PostgresSchemaOptions,
174
197
  ): SchemaChange[] {
175
198
  const changes: SchemaChange[] = [];
@@ -249,7 +272,7 @@ function planExistingTable(
249
272
  description: `create ${index.unique ? 'unique ' : ''}index ${index.name} on ${name}`,
250
273
  // A unique index can fail on the rows already stored, and it changes what may be written: a reviewed decision
251
274
  kind: index.unique ? 'migration' : 'automatic',
252
- statements: [index.create],
275
+ statements: index.unique ? liveUniqueMigration(table, existing, index, wanted) : [index.create],
253
276
  table: name,
254
277
  });
255
278
  }
@@ -294,9 +317,97 @@ function planExistingTable(
294
317
  table: name,
295
318
  });
296
319
  }
320
+ changes.push(...planSync(table, existing, sequences, options));
297
321
  return changes;
298
322
  }
299
323
 
324
+ /**
325
+ * A synchronized table's change order: the trigger of the current version, its sequence and the application role's
326
+ * use of it. A trigger of an earlier version is replaced; a table that is no longer synchronized keeps its trigger
327
+ * (and its numbering) until a migration removes it
328
+ */
329
+ function planSync(
330
+ table: DesiredTable,
331
+ existing: CatalogTable,
332
+ sequences: Map<string, boolean>,
333
+ options: PostgresSchemaOptions,
334
+ ): SchemaChange[] {
335
+ const name = table.name;
336
+ const quoted = quoteIdentifier(name);
337
+ if (!table.sync) {
338
+ return existing.syncTriggers.size
339
+ ? [
340
+ {
341
+ description: `${name} is no longer synchronized; its change-order trigger is kept until a migration drops it`,
342
+ kind: 'notice',
343
+ statements: [],
344
+ table: name,
345
+ },
346
+ ]
347
+ : [];
348
+ }
349
+ if (!existing.syncTriggers.has(SYNC_TRIGGER)) {
350
+ const outdated = [...existing.syncTriggers].map(
351
+ (trigger) => `DROP TRIGGER IF EXISTS ${quoteIdentifier(trigger)} ON ${quoted}`,
352
+ );
353
+ return [
354
+ {
355
+ description: `${outdated.length ? 'replace' : 'add'} the change-order trigger of ${name}`,
356
+ kind: 'automatic',
357
+ statements: [...outdated, ...syncTableStatements(name, table.sync, options.appRole)],
358
+ table: name,
359
+ },
360
+ ];
361
+ }
362
+ if (!sequences.get(table.sync.sequence)) {
363
+ return [
364
+ {
365
+ description: `let ${options.appRole} use the change-order sequence of ${name}`,
366
+ kind: 'automatic',
367
+ statements: syncTableStatements(name, table.sync, options.appRole).slice(0, 2),
368
+ table: name,
369
+ },
370
+ ];
371
+ }
372
+ return [];
373
+ }
374
+
375
+ /**
376
+ * The statements of a new unique index's migration. A synchronized model's index reads `deleted_at`: when the table
377
+ * does not have it yet (an existing model that became synchronized), the column comes first — the boot that refuses the
378
+ * index never adds it. A framework unique index over the same fields that the model no longer declares is the one the
379
+ * live-only index replaces: it goes last, because a tombstone (which keeps no values) would collide on it
380
+ */
381
+ function liveUniqueMigration(
382
+ table: DesiredTable,
383
+ existing: CatalogTable,
384
+ index: DesiredTable['indexes'][number],
385
+ wanted: Set<string>,
386
+ ): string[] {
387
+ const statements: string[] = [];
388
+ const deletedAt = table.columns.find((column) => column.name === 'deleted_at');
389
+ if (table.sync && deletedAt && !existing.columns.has('deleted_at') && index.create.includes('"deleted_at"')) {
390
+ statements.push(
391
+ `ALTER TABLE ${quoteIdentifier(table.name)} ADD COLUMN ${columnDefinition({ ...deletedAt, primaryKey: false })}`,
392
+ );
393
+ }
394
+ statements.push(index.create);
395
+ if (table.sync) {
396
+ const stem = indexStem(index.name);
397
+ for (const [old, unique] of existing.indexes) {
398
+ if (unique && !wanted.has(old) && indexStem(old) === stem) {
399
+ statements.push(`DROP INDEX IF EXISTS ${quoteIdentifier(old)}`);
400
+ }
401
+ }
402
+ }
403
+ return statements;
404
+ }
405
+
406
+ /** A framework index name without its definition hash (`ux_<table>__<fields>_<hash>`) */
407
+ function indexStem(name: string): string {
408
+ return name.replace(/_[0-9a-f]{8}$/, '');
409
+ }
410
+
300
411
  function sameType(
301
412
  column: DesiredTable['columns'][number],
302
413
  current: { collation: null | string; type: string },
@@ -321,7 +432,7 @@ function typeChange(
321
432
  };
322
433
  }
323
434
 
324
- async function readCatalog(run: Run, tables: string[], appRole: string): Promise<Catalog> {
435
+ async function readCatalog(run: Run, tables: string[], appRole: string, sequences: string[]): Promise<Catalog> {
325
436
  // One query after the other: inside the migration transaction they share a single connection
326
437
  const schemaResult = await run({
327
438
  params: [appRole],
@@ -359,14 +470,31 @@ async function readCatalog(run: Run, tables: string[], appRole: string): Promise
359
470
  WHERE n.nspname = current_schema() AND c.relname = ANY($1) AND k.contype = 'c'`,
360
471
  });
361
472
  const functionResult = await run({
362
- params: [TENANT_FILTER_FUNCTION],
363
- text: `SELECT obj_description(p.oid, 'pg_proc') AS version
473
+ params: [[TENANT_FILTER_FUNCTION, SYNC_STAMP_FUNCTION]],
474
+ text: `SELECT p.proname AS name, obj_description(p.oid, 'pg_proc') AS version
364
475
  FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
365
- WHERE n.nspname = current_schema() AND p.proname = $1 AND p.pronargs = 0`,
476
+ WHERE n.nspname = current_schema() AND p.proname = ANY($1) AND p.pronargs = 0`,
366
477
  });
478
+ const triggerResult = await run({
479
+ params: [tables],
480
+ text: `SELECT c.relname AS table, t.tgname AS name
481
+ FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid JOIN pg_namespace n ON n.oid = c.relnamespace
482
+ WHERE n.nspname = current_schema() AND c.relname = ANY($1) AND NOT t.tgisinternal AND t.tgname LIKE 'lt\\_sync\\_%'`,
483
+ });
484
+ const sequenceResult = sequences.length
485
+ ? await run({
486
+ params: [sequences, appRole],
487
+ text: `SELECT c.relname AS name, has_sequence_privilege($2, c.oid, 'USAGE') AS usage
488
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
489
+ WHERE n.nspname = current_schema() AND c.relkind = 'S' AND c.relname = ANY($1)`,
490
+ })
491
+ : { rowCount: 0, rows: [] };
492
+ const versionOf = (fn: string) => functionResult.rows.find((row) => row.name === fn)?.version ?? null;
367
493
 
368
494
  const catalog: Catalog = {
369
- functionVersion: functionResult.rows[0]?.version ?? null,
495
+ functionVersion: versionOf(TENANT_FILTER_FUNCTION),
496
+ sequences: new Map(sequenceResult.rows.map((row: { name: string; usage: boolean }) => [row.name, row.usage])),
497
+ syncFunctionVersion: versionOf(SYNC_STAMP_FUNCTION),
370
498
  schema: schemaResult.rows[0].schema,
371
499
  schemaUsage: schemaResult.rows[0].usage,
372
500
  tables: new Map(),
@@ -380,8 +508,12 @@ async function readCatalog(run: Run, tables: string[], appRole: string): Promise
380
508
  indexes: new Map(),
381
509
  policyVersion: row.policy_version ?? null,
382
510
  rls: row.rls,
511
+ syncTriggers: new Set(),
383
512
  });
384
513
  }
514
+ for (const row of triggerResult.rows) {
515
+ catalog.tables.get(row.table)?.syncTriggers.add(row.name);
516
+ }
385
517
  for (const row of columnResult.rows) {
386
518
  catalog.tables.get(row.table)?.columns.set(row.name, { collation: row.collation ?? null, type: row.type });
387
519
  }