@openlup/platform 0.28.0 → 0.30.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 (241) hide show
  1. package/CHANGELOG.md +369 -156
  2. package/README.md +220 -4
  3. package/dist/composition/index.d.ts +1 -0
  4. package/dist/composition/index.js +1 -0
  5. package/dist/composition/marketingContribution.d.ts +21 -0
  6. package/dist/composition/marketingContribution.js +29 -0
  7. package/dist/composition/nodeHost.d.ts +1 -1
  8. package/dist/composition/nodeHost.js +3 -2
  9. package/dist/lifecycle/appUpgrade.d.ts +52 -0
  10. package/dist/lifecycle/appUpgrade.js +180 -0
  11. package/dist/lifecycle/index.d.ts +2 -0
  12. package/dist/lifecycle/index.js +2 -0
  13. package/dist/lifecycle/postgresInstallQualification.d.ts +51 -0
  14. package/dist/lifecycle/postgresInstallQualification.js +74 -0
  15. package/dist/marketing/acquisitionCaseSchemas.d.ts +103 -0
  16. package/dist/marketing/acquisitionCaseSchemas.js +53 -0
  17. package/dist/marketing/acquisitionEvidenceSchemas.d.ts +105 -0
  18. package/dist/marketing/acquisitionEvidenceSchemas.js +55 -0
  19. package/dist/marketing/analytics/dataLayer.d.ts +18 -0
  20. package/dist/marketing/analytics/dataLayer.js +111 -0
  21. package/dist/marketing/analytics/noop.d.ts +9 -0
  22. package/dist/marketing/analytics/noop.js +8 -0
  23. package/dist/marketing/analytics/purchaseFacts.d.ts +4 -0
  24. package/dist/marketing/analytics/purchaseFacts.js +69 -0
  25. package/dist/marketing/analytics/routePolicy.d.ts +10 -0
  26. package/dist/marketing/analytics/routePolicy.js +59 -0
  27. package/dist/marketing/consentEvidence.d.ts +8 -0
  28. package/dist/marketing/consentEvidence.js +32 -0
  29. package/dist/marketing/contactLegal.d.ts +14 -0
  30. package/dist/marketing/contactLegal.js +19 -0
  31. package/dist/marketing/contracts/acquisition.d.ts +88 -0
  32. package/dist/marketing/contracts/acquisition.js +1 -0
  33. package/dist/marketing/contracts/acquisitionPorts.d.ts +49 -0
  34. package/dist/marketing/contracts/acquisitionPorts.js +1 -0
  35. package/dist/marketing/contracts/analytics.d.ts +144 -0
  36. package/dist/marketing/contracts/analytics.js +1 -0
  37. package/dist/marketing/contracts/contactLegal.d.ts +53 -0
  38. package/dist/marketing/contracts/contactLegal.js +1 -0
  39. package/dist/marketing/contracts/index.d.ts +6 -0
  40. package/dist/marketing/contracts/index.js +1 -0
  41. package/dist/marketing/contracts/prelaunch.d.ts +39 -0
  42. package/dist/marketing/contracts/prelaunch.js +1 -0
  43. package/dist/marketing/index.d.ts +16 -0
  44. package/dist/marketing/index.js +14 -0
  45. package/dist/marketing/prelaunchAcquisitionSchemas.d.ts +116 -0
  46. package/dist/marketing/prelaunchAcquisitionSchemas.js +51 -0
  47. package/dist/marketing/seo/jsonLd.d.ts +157 -0
  48. package/dist/marketing/seo/jsonLd.js +127 -0
  49. package/dist/marketing/seo/metadata.d.ts +41 -0
  50. package/dist/marketing/seo/metadata.js +36 -0
  51. package/dist/marketing/seo/serializeJsonLd.d.ts +1 -0
  52. package/dist/marketing/seo/serializeJsonLd.js +6 -0
  53. package/dist/marketing/seo/urls.d.ts +14 -0
  54. package/dist/marketing/seo/urls.js +23 -0
  55. package/dist/marketing/server/acquisitionCaseDirect.d.ts +4 -0
  56. package/dist/marketing/server/acquisitionCaseDirect.js +88 -0
  57. package/dist/marketing/server/acquisitionCaseHandlers.d.ts +28 -0
  58. package/dist/marketing/server/acquisitionCaseHandlers.js +40 -0
  59. package/dist/marketing/server/acquisitionCasePorts.d.ts +42 -0
  60. package/dist/marketing/server/acquisitionCasePorts.js +1 -0
  61. package/dist/marketing/server/acquisitionEvidenceDirect.d.ts +5 -0
  62. package/dist/marketing/server/acquisitionEvidenceDirect.js +100 -0
  63. package/dist/marketing/server/analyticsContext.d.ts +3 -0
  64. package/dist/marketing/server/analyticsContext.js +65 -0
  65. package/dist/marketing/server/analyticsContextPorts.d.ts +31 -0
  66. package/dist/marketing/server/analyticsContextPorts.js +1 -0
  67. package/dist/marketing/server/bindings.d.ts +14 -0
  68. package/dist/marketing/server/bindings.js +18 -0
  69. package/dist/marketing/server/directPorts.d.ts +26 -0
  70. package/dist/marketing/server/directPorts.js +1 -0
  71. package/dist/marketing/server/index.d.ts +14 -0
  72. package/dist/marketing/server/index.js +11 -0
  73. package/dist/marketing/server/postgres/acquisitionCaseRoutines.d.ts +10 -0
  74. package/dist/marketing/server/postgres/acquisitionCaseRoutines.js +204 -0
  75. package/dist/marketing/server/postgres/acquisitionEvidenceRoutines.d.ts +10 -0
  76. package/dist/marketing/server/postgres/acquisitionEvidenceRoutines.js +107 -0
  77. package/dist/marketing/server/postgres/index.d.ts +3 -0
  78. package/dist/marketing/server/postgres/index.js +3 -0
  79. package/dist/marketing/server/prelaunchAcquisitionDirect.d.ts +4 -0
  80. package/dist/marketing/server/prelaunchAcquisitionDirect.js +122 -0
  81. package/dist/marketing/server/privacyContributor.d.ts +66 -0
  82. package/dist/marketing/server/privacyContributor.js +87 -0
  83. package/dist/marketing/server/research/adminSurveyResponsesHandler.d.ts +9 -0
  84. package/dist/marketing/server/research/adminSurveyResponsesHandler.js +53 -0
  85. package/dist/marketing/server/research/managedSurveyResponseSubmit.d.ts +39 -0
  86. package/dist/marketing/server/research/managedSurveyResponseSubmit.js +102 -0
  87. package/dist/marketing/server/research/surveyResponseSubmitHandler.d.ts +8 -0
  88. package/dist/marketing/server/research/surveyResponseSubmitHandler.js +41 -0
  89. package/dist/marketing/validation.d.ts +2 -0
  90. package/dist/marketing/validation.js +3 -0
  91. package/dist/migrations/postgresRunner.d.ts +9 -0
  92. package/dist/migrations/postgresRunner.js +17 -10
  93. package/dist/operations/adminAlertsSchemas.d.ts +153 -0
  94. package/dist/operations/adminAlertsSchemas.js +69 -0
  95. package/dist/operations/adminAlertsView.d.ts +73 -0
  96. package/dist/operations/adminAlertsView.js +184 -0
  97. package/dist/operations/alertPagingPolicy.d.ts +15 -0
  98. package/dist/operations/alertPagingPolicy.js +43 -0
  99. package/dist/operations/alertThrottlePolicy.d.ts +3 -0
  100. package/dist/operations/alertThrottlePolicy.js +18 -0
  101. package/dist/operations/clients.d.ts +17 -0
  102. package/dist/operations/clients.js +32 -0
  103. package/dist/operations/contracts/admin.d.ts +93 -0
  104. package/dist/operations/contracts/admin.js +1 -0
  105. package/dist/operations/contracts/adminAlertsContracts.d.ts +40 -0
  106. package/dist/operations/contracts/adminAlertsContracts.js +1 -0
  107. package/dist/operations/contracts/alertHumanContext.d.ts +7 -0
  108. package/dist/operations/contracts/alertHumanContext.js +1 -0
  109. package/dist/operations/contracts/index.d.ts +6 -0
  110. package/dist/operations/contracts/index.js +6 -0
  111. package/dist/operations/contracts/observabilityContracts.d.ts +41 -0
  112. package/dist/operations/contracts/observabilityContracts.js +1 -0
  113. package/dist/operations/contracts/observabilityPorts.d.ts +18 -0
  114. package/dist/operations/contracts/observabilityPorts.js +1 -0
  115. package/dist/operations/contracts/ports.d.ts +19 -0
  116. package/dist/operations/contracts/ports.js +1 -0
  117. package/dist/operations/errors.d.ts +6 -0
  118. package/dist/operations/errors.js +6 -0
  119. package/dist/operations/index.d.ts +9 -0
  120. package/dist/operations/index.js +9 -0
  121. package/dist/operations/schemas.d.ts +138 -0
  122. package/dist/operations/schemas.js +104 -0
  123. package/dist/operations/server/adminAlertsHandlers.d.ts +16 -0
  124. package/dist/operations/server/adminAlertsHandlers.js +35 -0
  125. package/dist/operations/server/adminPipelineHandlers.d.ts +12 -0
  126. package/dist/operations/server/adminPipelineHandlers.js +48 -0
  127. package/dist/operations/server/adminSettingsHandlers.d.ts +14 -0
  128. package/dist/operations/server/adminSettingsHandlers.js +82 -0
  129. package/dist/operations/server/controlPlaneHandler.d.ts +7 -0
  130. package/dist/operations/server/controlPlaneHandler.js +54 -0
  131. package/dist/operations/server/controlPlaneStore.d.ts +12 -0
  132. package/dist/operations/server/controlPlaneStore.js +138 -0
  133. package/dist/operations/server/controlPlaneWatchdog.d.ts +7 -0
  134. package/dist/operations/server/controlPlaneWatchdog.js +20 -0
  135. package/dist/operations/server/index.d.ts +12 -0
  136. package/dist/operations/server/index.js +12 -0
  137. package/dist/operations/server/migrations.d.ts +14 -0
  138. package/dist/operations/server/migrations.js +7 -0
  139. package/dist/operations/server/platformWatchdogDecision.d.ts +34 -0
  140. package/dist/operations/server/platformWatchdogDecision.js +71 -0
  141. package/dist/operations/server/platformWatchdogDeliveryPolicy.d.ts +10 -0
  142. package/dist/operations/server/platformWatchdogDeliveryPolicy.js +53 -0
  143. package/dist/operations/server/platformWatchdogQueue.d.ts +11 -0
  144. package/dist/operations/server/platformWatchdogQueue.js +94 -0
  145. package/dist/operations/server/platformWatchdogService.d.ts +47 -0
  146. package/dist/operations/server/platformWatchdogService.js +105 -0
  147. package/dist/operations/validation.d.ts +2 -0
  148. package/dist/operations/validation.js +3 -0
  149. package/dist/portability/filesystemObjects.d.ts +12 -0
  150. package/dist/portability/filesystemObjects.js +191 -0
  151. package/dist/portability/index.d.ts +5 -0
  152. package/dist/portability/index.js +4 -0
  153. package/dist/portability/objects.d.ts +63 -0
  154. package/dist/portability/objects.js +138 -0
  155. package/dist/portability/postgresRows.d.ts +64 -0
  156. package/dist/portability/postgresRows.js +182 -0
  157. package/dist/scheduler/advisoryLock.js +20 -2
  158. package/dist/scheduler/nodeScheduler.d.ts +2 -2
  159. package/dist/scheduler/nodeScheduler.js +35 -6
  160. package/package.json +47 -2
  161. package/release-gates.json +143 -9
  162. package/sql/20260814010000_platform_control_plane.sql +286 -0
  163. package/src/composition/index.ts +1 -0
  164. package/src/composition/marketingContribution.ts +37 -0
  165. package/src/composition/nodeHost.ts +4 -3
  166. package/src/lifecycle/appUpgrade.ts +186 -0
  167. package/src/lifecycle/index.ts +19 -0
  168. package/src/lifecycle/postgresInstallQualification.ts +125 -0
  169. package/src/marketing/acquisitionCaseSchemas.ts +75 -0
  170. package/src/marketing/acquisitionEvidenceSchemas.ts +72 -0
  171. package/src/marketing/analytics/dataLayer.ts +100 -0
  172. package/src/marketing/analytics/noop.ts +14 -0
  173. package/src/marketing/analytics/purchaseFacts.ts +63 -0
  174. package/src/marketing/analytics/routePolicy.ts +55 -0
  175. package/src/marketing/consentEvidence.ts +36 -0
  176. package/src/marketing/contactLegal.ts +22 -0
  177. package/src/marketing/contracts/acquisition.ts +50 -0
  178. package/src/marketing/contracts/acquisitionPorts.ts +48 -0
  179. package/src/marketing/contracts/analytics.ts +88 -0
  180. package/src/marketing/contracts/contactLegal.ts +41 -0
  181. package/src/marketing/contracts/index.ts +17 -0
  182. package/src/marketing/contracts/prelaunch.ts +15 -0
  183. package/src/marketing/index.ts +16 -0
  184. package/src/marketing/prelaunchAcquisitionSchemas.ts +64 -0
  185. package/src/marketing/seo/jsonLd.ts +153 -0
  186. package/src/marketing/seo/metadata.ts +62 -0
  187. package/src/marketing/seo/serializeJsonLd.ts +6 -0
  188. package/src/marketing/seo/urls.ts +34 -0
  189. package/src/marketing/server/acquisitionCaseDirect.ts +67 -0
  190. package/src/marketing/server/acquisitionCaseHandlers.ts +57 -0
  191. package/src/marketing/server/acquisitionCasePorts.ts +42 -0
  192. package/src/marketing/server/acquisitionEvidenceDirect.ts +76 -0
  193. package/src/marketing/server/analyticsContext.ts +55 -0
  194. package/src/marketing/server/analyticsContextPorts.ts +28 -0
  195. package/src/marketing/server/bindings.ts +27 -0
  196. package/src/marketing/server/directPorts.ts +27 -0
  197. package/src/marketing/server/index.ts +14 -0
  198. package/src/marketing/server/postgres/acquisitionCaseRoutines.ts +218 -0
  199. package/src/marketing/server/postgres/acquisitionEvidenceRoutines.ts +103 -0
  200. package/src/marketing/server/postgres/index.ts +3 -0
  201. package/src/marketing/server/prelaunchAcquisitionDirect.ts +121 -0
  202. package/src/marketing/server/privacyContributor.ts +123 -0
  203. package/src/marketing/server/research/adminSurveyResponsesHandler.ts +78 -0
  204. package/src/marketing/server/research/managedSurveyResponseSubmit.ts +158 -0
  205. package/src/marketing/server/research/surveyResponseSubmitHandler.ts +57 -0
  206. package/src/marketing/validation.ts +3 -0
  207. package/src/migrations/postgresRunner.ts +24 -9
  208. package/src/operations/adminAlertsSchemas.ts +78 -0
  209. package/src/operations/adminAlertsView.ts +238 -0
  210. package/src/operations/alertPagingPolicy.ts +50 -0
  211. package/src/operations/alertThrottlePolicy.ts +22 -0
  212. package/src/operations/clients.ts +44 -0
  213. package/src/operations/contracts/admin.ts +24 -0
  214. package/src/operations/contracts/adminAlertsContracts.ts +13 -0
  215. package/src/operations/contracts/alertHumanContext.ts +17 -0
  216. package/src/operations/contracts/index.ts +6 -0
  217. package/src/operations/contracts/observabilityContracts.ts +40 -0
  218. package/src/operations/contracts/observabilityPorts.ts +30 -0
  219. package/src/operations/contracts/ports.ts +22 -0
  220. package/src/operations/errors.ts +6 -0
  221. package/src/operations/index.ts +9 -0
  222. package/src/operations/schemas.ts +122 -0
  223. package/src/operations/server/adminAlertsHandlers.ts +61 -0
  224. package/src/operations/server/adminPipelineHandlers.ts +73 -0
  225. package/src/operations/server/adminSettingsHandlers.ts +114 -0
  226. package/src/operations/server/controlPlaneHandler.ts +56 -0
  227. package/src/operations/server/controlPlaneStore.ts +150 -0
  228. package/src/operations/server/controlPlaneWatchdog.ts +28 -0
  229. package/src/operations/server/index.ts +12 -0
  230. package/src/operations/server/migrations.ts +7 -0
  231. package/src/operations/server/platformWatchdogDecision.ts +93 -0
  232. package/src/operations/server/platformWatchdogDeliveryPolicy.ts +92 -0
  233. package/src/operations/server/platformWatchdogQueue.ts +96 -0
  234. package/src/operations/server/platformWatchdogService.ts +209 -0
  235. package/src/operations/validation.ts +3 -0
  236. package/src/portability/filesystemObjects.ts +138 -0
  237. package/src/portability/index.ts +12 -0
  238. package/src/portability/objects.ts +160 -0
  239. package/src/portability/postgresRows.ts +169 -0
  240. package/src/scheduler/advisoryLock.ts +20 -5
  241. package/src/scheduler/nodeScheduler.ts +28 -8
@@ -0,0 +1,169 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ /** A finite compatible-row utility, not a complete-store migration profile. */
4
+ const TABLES = [
5
+ "clients", "subscriptions", "subscription_events", "subscription_pause_windows",
6
+ "subscription_renewal_terms", "subscription_renewal_term_lines", "subscription_renewal_operations",
7
+ "subscription_cycles", "commerce_orders", "commerce_order_items", "commerce_settlement_intents",
8
+ "commerce_settlement_transitions", "payment_truth_events", "payment_truth_reconciliation_evidence",
9
+ "subscription_renewal_outcomes", "outbox_events", "invoicing_duties", "invoicing_fact_revisions",
10
+ "invoicing_effect_receipts", "invoicing_capture_faults",
11
+ ] as const;
12
+ export type CompatiblePostgresTable = typeof TABLES[number];
13
+ export interface TransferPostgresQuery {
14
+ query<Row extends Record<string, unknown> = Record<string, unknown>>(sql: string, values?: readonly unknown[]): Promise<{ rows: Row[] }>;
15
+ }
16
+ export interface CompatiblePostgresSnapshot {
17
+ /** Own one read-only, repeatable-read transaction for the complete callback. */
18
+ withSnapshot<T>(work: (db: TransferPostgresQuery) => Promise<T>): Promise<T>;
19
+ }
20
+ export interface CompatiblePostgresTarget {
21
+ /** Own one transaction; commit only after the complete callback succeeds. */
22
+ withTransaction<T>(work: (db: TransferPostgresQuery) => Promise<T>): Promise<T>;
23
+ }
24
+ type Column = { name: string; type: string; key: boolean };
25
+ type TableRows = { table: CompatiblePostgresTable; columns: Column[]; rows: string[] };
26
+ export interface CompatiblePostgresPacket {
27
+ format: "openlup.compatible-rows.v1";
28
+ sourceId: string;
29
+ /** Opaque selected-catalogue identity supplied by the validated host capability. */
30
+ catalogueId: string;
31
+ tables: TableRows[];
32
+ sha256: string;
33
+ }
34
+ export type PostgresTransferFailure = "invalid_input" | "unsupported_table" | "schema_conflict"
35
+ | "insert_trigger_requires_owner" | "target_conflict" | "limit_exceeded" | "database_unavailable";
36
+ export class PostgresTransferError extends Error {
37
+ constructor(readonly code: PostgresTransferFailure, readonly table?: string) {
38
+ super(`postgres_transfer_${code}${table ? `:${table}` : ""}`);
39
+ this.name = "PostgresTransferError";
40
+ }
41
+ }
42
+ const LIMIT_ROWS = 10000, LIMIT_BYTES = 64 * 1024 * 1024;
43
+
44
+ /** Data only: no schema, role, grant, migration ledger, control or provider-internal copy. */
45
+ export async function captureCompatiblePostgresRows(input: {
46
+ source: CompatiblePostgresSnapshot; sourceId: string; catalogueId: string;
47
+ tables: readonly CompatiblePostgresTable[]; maximumRows: number; maximumBytes: number;
48
+ }): Promise<CompatiblePostgresPacket> {
49
+ const { sourceId, catalogueId, maximumRows, maximumBytes } = input;
50
+ if (!identity(sourceId) || !identity(catalogueId) || !ceiling(maximumRows, LIMIT_ROWS) || !ceiling(maximumBytes, LIMIT_BYTES)) fail("invalid_input");
51
+ const names = tableNames(input.tables);
52
+ try {
53
+ const tables = await input.source.withSnapshot(async db => {
54
+ const mode = (await db.query("SELECT current_setting('transaction_read_only') AS readonly, current_setting('transaction_isolation') AS isolation")).rows[0];
55
+ if (mode?.readonly !== "on" || mode.isolation !== "repeatable read") fail("invalid_input");
56
+ await db.query("SET LOCAL TimeZone = 'UTC'");
57
+ let count = 0, bytes = 0;
58
+ const result: TableRows[] = [];
59
+ for (const table of names) {
60
+ const columns = await inspect(db, table);
61
+ const order = columns.filter(column => column.key).map(column => quoted(column.name)).join(",");
62
+ // PostgreSQL serializes bigint, numeric, bytea and timestamp values.
63
+ // Retain that JSON text unchanged; a JS numeric round trip is lossy.
64
+ const rows = (await db.query<{ value: string }>(`SELECT to_jsonb(t)::text AS value FROM public.${quoted(table)} t ORDER BY ${order} LIMIT $1`, [maximumRows + 1])).rows.map(row => row.value);
65
+ for (const row of rows) { count++; bytes += Buffer.byteLength(row); }
66
+ if (count > maximumRows || bytes > maximumBytes) fail("limit_exceeded", table);
67
+ result.push({ table, columns, rows });
68
+ }
69
+ return result;
70
+ });
71
+ const packet = { format: "openlup.compatible-rows.v1" as const, sourceId, catalogueId, tables };
72
+ return { ...packet, sha256: digest(packet) };
73
+ } catch (error) { throw redacted(error); }
74
+ }
75
+
76
+ /**
77
+ * Atomic insert and exact replay. A differing row refuses; there is no upsert,
78
+ * deletion scan, status reset or permission change. Existing parent rows remain
79
+ * prerequisites, and this utility never authorizes target effect execution.
80
+ */
81
+ export async function restoreCompatiblePostgresRows(input: {
82
+ target: CompatiblePostgresTarget; catalogueId: string; packet: CompatiblePostgresPacket;
83
+ }): Promise<{ inserted: number; replayed: number; sha256: string }> {
84
+ // Own immutable input bytes throughout async validation and writes.
85
+ let packet: CompatiblePostgresPacket;
86
+ try { packet = JSON.parse(JSON.stringify(input.packet)) as CompatiblePostgresPacket; validatePacket(packet); }
87
+ catch (error) { if (error instanceof PostgresTransferError) throw error; fail("invalid_input"); }
88
+ if (packet.catalogueId !== input.catalogueId) fail("schema_conflict");
89
+ try {
90
+ return await input.target.withTransaction(async db => {
91
+ const mode = (await db.query("SELECT current_setting('session_replication_role') AS mode")).rows[0];
92
+ if (mode?.mode !== "origin") fail("invalid_input");
93
+ await db.query("SET LOCAL TimeZone = 'UTC'");
94
+ // Serializes writers and prevents DDL between inspection and inserts.
95
+ // Fixed lock ordering avoids deadlock between overlapping packet imports.
96
+ for (const table of [...packet.tables].sort((a, b) => a.table.localeCompare(b.table))) {
97
+ await db.query(`LOCK TABLE public.${quoted(table.table)} IN SHARE ROW EXCLUSIVE MODE`);
98
+ }
99
+ for (const table of packet.tables) {
100
+ if (JSON.stringify(await inspect(db, table.table)) !== JSON.stringify(table.columns)) fail("schema_conflict", table.table);
101
+ const triggers = (await db.query(`SELECT 1 FROM pg_catalog.pg_trigger
102
+ WHERE tgrelid=$1::regclass AND NOT tgisinternal AND (tgtype & 4) <> 0 AND tgenabled IN ('O','A')`, [`public.${table.table}`])).rows;
103
+ if (triggers.length) fail("insert_trigger_requires_owner", table.table);
104
+ }
105
+ let inserted = 0, replayed = 0;
106
+ for (const table of packet.tables) {
107
+ const name = `public.${quoted(table.table)}`;
108
+ const columns = table.columns.map(column => quoted(column.name)).join(",");
109
+ const keys = table.columns.filter(column => column.key).map(column => `t.${quoted(column.name)} IS NOT DISTINCT FROM p.${quoted(column.name)}`).join(" AND ");
110
+ for (const row of table.rows) {
111
+ const write = await db.query(`INSERT INTO ${name} (${columns}) SELECT ${columns} FROM jsonb_populate_record(NULL::${name}, $1::jsonb)
112
+ ON CONFLICT DO NOTHING RETURNING 1 AS inserted`, [row]);
113
+ const read = await db.query<{ identical: boolean }>(`SELECT to_jsonb(t) = $1::jsonb AS identical FROM ${name} t,
114
+ jsonb_populate_record(NULL::${name}, $1::jsonb) p WHERE ${keys}`, [row]);
115
+ if (read.rows.length !== 1 || read.rows[0]?.identical !== true) fail("target_conflict", table.table);
116
+ if (write.rows.length) inserted++; else replayed++;
117
+ }
118
+ }
119
+ return { inserted, replayed, sha256: packet.sha256 };
120
+ });
121
+ } catch (error) { throw redacted(error); }
122
+ }
123
+
124
+ async function inspect(db: TransferPostgresQuery, table: CompatiblePostgresTable): Promise<Column[]> {
125
+ const rows = (await db.query<{ name: string; type: string; key: boolean; generated: string; identity: string }>(`SELECT a.attname AS name,
126
+ pg_catalog.format_type(a.atttypid,a.atttypmod) AS type, COALESCE(a.attnum = ANY(i.indkey),false) AS key,
127
+ a.attgenerated AS generated, a.attidentity AS identity
128
+ FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_class c ON c.oid=a.attrelid
129
+ LEFT JOIN pg_catalog.pg_index i ON i.indrelid=c.oid AND i.indisprimary
130
+ WHERE a.attrelid=$1::regclass AND c.relkind='r' AND a.attnum>0 AND NOT a.attisdropped ORDER BY a.attnum`, [`public.${table}`])).rows;
131
+ if (!rows.length || !rows.some(row => row.key) || rows.some(row => row.generated || row.identity || !identifier(row.name))) fail("schema_conflict", table);
132
+ return rows.map(({ name, type, key }) => ({ name, type, key }));
133
+ }
134
+ function tableNames(tables: readonly CompatiblePostgresTable[]): CompatiblePostgresTable[] {
135
+ if (!Array.isArray(tables) || !tables.length || new Set(tables).size !== tables.length) fail("invalid_input");
136
+ for (const table of tables) if (!(TABLES as readonly string[]).includes(table)) fail("unsupported_table");
137
+ return TABLES.filter(table => tables.includes(table));
138
+ }
139
+ function validatePacket(packet: CompatiblePostgresPacket): void {
140
+ if (!packet || packet.format !== "openlup.compatible-rows.v1" || !identity(packet.sourceId) || !identity(packet.catalogueId)
141
+ || !Array.isArray(packet.tables) || !/^[a-f0-9]{64}$/.test(packet.sha256)) fail("invalid_input");
142
+ const { sha256: _hash, ...payload } = packet;
143
+ if (digest(payload) !== packet.sha256) fail("invalid_input");
144
+ const names = tableNames(packet.tables.map(table => table.table));
145
+ if (names.some((name, index) => name !== packet.tables[index]?.table)) fail("invalid_input");
146
+ let count = 0, bytes = 0;
147
+ for (const table of packet.tables) {
148
+ if (!Array.isArray(table.columns) || !table.columns.length || !table.columns.some(column => column.key)
149
+ || table.columns.some(column => !identifier(column.name) || !identity(column.type) || typeof column.key !== "boolean")
150
+ || new Set(table.columns.map(column => column.name)).size !== table.columns.length || !Array.isArray(table.rows)) fail("invalid_input");
151
+ for (const row of table.rows) {
152
+ if (typeof row !== "string") fail("invalid_input");
153
+ // Inspect only field names. Never stringify parsed values back to SQL.
154
+ const parsed: unknown = JSON.parse(row);
155
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)
156
+ || Object.keys(parsed).sort().join("\0") !== table.columns.map(column => column.name).sort().join("\0")) fail("invalid_input");
157
+ count++; bytes += Buffer.byteLength(row);
158
+ }
159
+ }
160
+ if (count > LIMIT_ROWS || bytes > LIMIT_BYTES) fail("limit_exceeded");
161
+ }
162
+ function digest(value: unknown): string { return createHash("sha256").update(JSON.stringify(value)).digest("hex"); }
163
+ function identifier(value: string): boolean { return /^[a-z][a-z0-9_]{0,62}$/.test(value); }
164
+ function quoted(value: string): string { if (!identifier(value)) fail("invalid_input"); return `"${value}"`; }
165
+ function identity(value: unknown): value is string { return typeof value === "string" && value.length > 0 && value.length <= 1024
166
+ && !Array.from(value).some(character => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127); }
167
+ function ceiling(value: number, maximum: number): boolean { return Number.isSafeInteger(value) && value > 0 && value <= maximum; }
168
+ function fail(code: PostgresTransferFailure, table?: string): never { throw new PostgresTransferError(code, table); }
169
+ function redacted(error: unknown): PostgresTransferError { return error instanceof PostgresTransferError ? error : new PostgresTransferError("database_unavailable"); }
@@ -55,19 +55,34 @@ export function createPgAdvisoryLock(
55
55
  closeClient: () => Promise<void> = () => client.end?.() ?? Promise.resolve(),
56
56
  ): AdvisoryLock {
57
57
  let closePromise: Promise<void> | null = null;
58
+ // PostgreSQL grants reentrant session locks. Reserve the actual key locally
59
+ // before querying so pending/held acquisitions, including hash collisions, skip.
60
+ const reserved = new Set<number>();
58
61
  return {
59
62
  durability: "cluster",
60
63
  async tryAcquire(jobId) {
61
64
  const key = advisoryLockKey(jobId);
62
- const { rows } = await client.query<{ locked: boolean }>(
63
- "SELECT pg_try_advisory_lock($1) AS locked",
64
- [key],
65
- );
66
- return rows[0]?.locked === true;
65
+ if (reserved.has(key)) return false;
66
+ reserved.add(key);
67
+ try {
68
+ const { rows } = await client.query<{ locked: boolean }>(
69
+ "SELECT pg_try_advisory_lock($1) AS locked",
70
+ [key],
71
+ );
72
+ const acquired = rows[0]?.locked === true;
73
+ if (!acquired) reserved.delete(key);
74
+ return acquired;
75
+ } catch (error) {
76
+ reserved.delete(key);
77
+ throw error;
78
+ }
67
79
  },
68
80
  async release(jobId) {
69
81
  const key = advisoryLockKey(jobId);
70
82
  await client.query("SELECT pg_advisory_unlock($1)", [key]);
83
+ // A failed unlock keeps the reservation: another query on this session
84
+ // could otherwise reenter the still-held PostgreSQL lock.
85
+ reserved.delete(key);
71
86
  },
72
87
  close() {
73
88
  closePromise ??= closeClient();
@@ -30,12 +30,13 @@ export interface CronScheduler {
30
30
  interface RegisteredTimer {
31
31
  jobId: string;
32
32
  stop: () => void;
33
+ stopped: boolean;
33
34
  }
34
35
 
35
36
  export interface NodeSchedulerAdapter extends SchedulerPort {
36
37
  readonly jobs: readonly ScheduledJobDeclaration[];
37
- /** Stop all registered node-cron timers (graceful shutdown). */
38
- stopAll(): void;
38
+ /** Fence/stop current timers synchronously, then await admitted callbacks and lock release. Later registration remains supported. */
39
+ stopAll(): Promise<void>;
39
40
  }
40
41
 
41
42
  export interface CreateNodeSchedulerOptions {
@@ -92,6 +93,7 @@ export function createNodeScheduler(options: CreateNodeSchedulerOptions): NodeSc
92
93
  });
93
94
  const scheduleById = new Map(jobs.map((job) => [job.jobId, job.schedule]));
94
95
  const timers: RegisteredTimer[] = [];
96
+ const active = new Set<Promise<void>>();
95
97
 
96
98
  return {
97
99
  jobs,
@@ -106,18 +108,36 @@ export function createNodeScheduler(options: CreateNodeSchedulerOptions): NodeSc
106
108
  );
107
109
  }
108
110
  const guarded = withAdvisoryLock(input.jobId, lock, input.handler, onError);
109
- const timer = options.cron.schedule(schedule, guarded, { timezone: options.timezone });
110
- timers.push({ jobId: input.jobId, stop: timer.stop });
111
+ const registration: RegisteredTimer = { jobId: input.jobId, stop: () => {}, stopped: false };
112
+ const timer = options.cron.schedule(schedule, () => {
113
+ if (registration.stopped) return Promise.resolve();
114
+ // Own the callback before acquisition starts, including a pending acquisition.
115
+ const running = Promise.resolve().then(guarded);
116
+ active.add(running);
117
+ void running.then(() => active.delete(running), () => active.delete(running));
118
+ return running;
119
+ }, { timezone: options.timezone });
120
+ registration.stop = timer.stop;
121
+ timers.push(registration);
111
122
  },
112
123
  verifyInvocation(req: HttpRequest) {
113
124
  // Self-hosted invocations still present the shared CRON_SECRET; reuse the canonical check.
114
125
  return verifyCronAuth(req, env);
115
126
  },
116
- stopAll() {
117
- for (const timer of timers) {
118
- timer.stop();
127
+ async stopAll() {
128
+ const stopped = timers.splice(0);
129
+ for (const timer of stopped) timer.stopped = true;
130
+ const admitted = [...active];
131
+ const failures: unknown[] = [];
132
+ for (const timer of stopped) {
133
+ try { timer.stop(); } catch (error) { failures.push(error); }
119
134
  }
120
- timers.length = 0;
135
+ const results = await Promise.allSettled(admitted);
136
+ for (const result of results) {
137
+ if (result.status === "rejected") failures.push(result.reason);
138
+ }
139
+ if (failures.length === 1) throw failures[0];
140
+ if (failures.length > 1) throw new AggregateError(failures, "node_scheduler_shutdown_failed");
121
141
  },
122
142
  };
123
143
  }