@openlup/platform 0.29.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 (121) hide show
  1. package/CHANGELOG.md +150 -138
  2. package/README.md +80 -3
  3. package/dist/composition/nodeHost.d.ts +1 -1
  4. package/dist/composition/nodeHost.js +3 -2
  5. package/dist/lifecycle/appUpgrade.d.ts +52 -0
  6. package/dist/lifecycle/appUpgrade.js +180 -0
  7. package/dist/lifecycle/index.d.ts +2 -0
  8. package/dist/lifecycle/index.js +2 -0
  9. package/dist/lifecycle/postgresInstallQualification.d.ts +51 -0
  10. package/dist/lifecycle/postgresInstallQualification.js +74 -0
  11. package/dist/migrations/postgresRunner.d.ts +9 -0
  12. package/dist/migrations/postgresRunner.js +17 -10
  13. package/dist/operations/adminAlertsSchemas.d.ts +153 -0
  14. package/dist/operations/adminAlertsSchemas.js +69 -0
  15. package/dist/operations/adminAlertsView.d.ts +73 -0
  16. package/dist/operations/adminAlertsView.js +184 -0
  17. package/dist/operations/alertPagingPolicy.d.ts +15 -0
  18. package/dist/operations/alertPagingPolicy.js +43 -0
  19. package/dist/operations/alertThrottlePolicy.d.ts +3 -0
  20. package/dist/operations/alertThrottlePolicy.js +18 -0
  21. package/dist/operations/clients.d.ts +17 -0
  22. package/dist/operations/clients.js +32 -0
  23. package/dist/operations/contracts/admin.d.ts +93 -0
  24. package/dist/operations/contracts/admin.js +1 -0
  25. package/dist/operations/contracts/adminAlertsContracts.d.ts +40 -0
  26. package/dist/operations/contracts/adminAlertsContracts.js +1 -0
  27. package/dist/operations/contracts/alertHumanContext.d.ts +7 -0
  28. package/dist/operations/contracts/alertHumanContext.js +1 -0
  29. package/dist/operations/contracts/index.d.ts +6 -0
  30. package/dist/operations/contracts/index.js +6 -0
  31. package/dist/operations/contracts/observabilityContracts.d.ts +41 -0
  32. package/dist/operations/contracts/observabilityContracts.js +1 -0
  33. package/dist/operations/contracts/observabilityPorts.d.ts +18 -0
  34. package/dist/operations/contracts/observabilityPorts.js +1 -0
  35. package/dist/operations/contracts/ports.d.ts +19 -0
  36. package/dist/operations/contracts/ports.js +1 -0
  37. package/dist/operations/errors.d.ts +6 -0
  38. package/dist/operations/errors.js +6 -0
  39. package/dist/operations/index.d.ts +9 -0
  40. package/dist/operations/index.js +9 -0
  41. package/dist/operations/schemas.d.ts +138 -0
  42. package/dist/operations/schemas.js +104 -0
  43. package/dist/operations/server/adminAlertsHandlers.d.ts +16 -0
  44. package/dist/operations/server/adminAlertsHandlers.js +35 -0
  45. package/dist/operations/server/adminPipelineHandlers.d.ts +12 -0
  46. package/dist/operations/server/adminPipelineHandlers.js +48 -0
  47. package/dist/operations/server/adminSettingsHandlers.d.ts +14 -0
  48. package/dist/operations/server/adminSettingsHandlers.js +82 -0
  49. package/dist/operations/server/controlPlaneHandler.d.ts +7 -0
  50. package/dist/operations/server/controlPlaneHandler.js +54 -0
  51. package/dist/operations/server/controlPlaneStore.d.ts +12 -0
  52. package/dist/operations/server/controlPlaneStore.js +138 -0
  53. package/dist/operations/server/controlPlaneWatchdog.d.ts +7 -0
  54. package/dist/operations/server/controlPlaneWatchdog.js +20 -0
  55. package/dist/operations/server/index.d.ts +12 -0
  56. package/dist/operations/server/index.js +12 -0
  57. package/dist/operations/server/migrations.d.ts +14 -0
  58. package/dist/operations/server/migrations.js +7 -0
  59. package/dist/operations/server/platformWatchdogDecision.d.ts +34 -0
  60. package/dist/operations/server/platformWatchdogDecision.js +71 -0
  61. package/dist/operations/server/platformWatchdogDeliveryPolicy.d.ts +10 -0
  62. package/dist/operations/server/platformWatchdogDeliveryPolicy.js +53 -0
  63. package/dist/operations/server/platformWatchdogQueue.d.ts +11 -0
  64. package/dist/operations/server/platformWatchdogQueue.js +94 -0
  65. package/dist/operations/server/platformWatchdogService.d.ts +47 -0
  66. package/dist/operations/server/platformWatchdogService.js +105 -0
  67. package/dist/operations/validation.d.ts +2 -0
  68. package/dist/operations/validation.js +3 -0
  69. package/dist/portability/filesystemObjects.d.ts +12 -0
  70. package/dist/portability/filesystemObjects.js +191 -0
  71. package/dist/portability/index.d.ts +5 -0
  72. package/dist/portability/index.js +4 -0
  73. package/dist/portability/objects.d.ts +63 -0
  74. package/dist/portability/objects.js +138 -0
  75. package/dist/portability/postgresRows.d.ts +64 -0
  76. package/dist/portability/postgresRows.js +182 -0
  77. package/dist/scheduler/advisoryLock.js +20 -2
  78. package/dist/scheduler/nodeScheduler.d.ts +2 -2
  79. package/dist/scheduler/nodeScheduler.js +35 -6
  80. package/package.json +27 -2
  81. package/release-gates.json +73 -7
  82. package/sql/20260814010000_platform_control_plane.sql +286 -0
  83. package/src/composition/nodeHost.ts +4 -3
  84. package/src/lifecycle/appUpgrade.ts +186 -0
  85. package/src/lifecycle/index.ts +19 -0
  86. package/src/lifecycle/postgresInstallQualification.ts +125 -0
  87. package/src/migrations/postgresRunner.ts +24 -9
  88. package/src/operations/adminAlertsSchemas.ts +78 -0
  89. package/src/operations/adminAlertsView.ts +238 -0
  90. package/src/operations/alertPagingPolicy.ts +50 -0
  91. package/src/operations/alertThrottlePolicy.ts +22 -0
  92. package/src/operations/clients.ts +44 -0
  93. package/src/operations/contracts/admin.ts +24 -0
  94. package/src/operations/contracts/adminAlertsContracts.ts +13 -0
  95. package/src/operations/contracts/alertHumanContext.ts +17 -0
  96. package/src/operations/contracts/index.ts +6 -0
  97. package/src/operations/contracts/observabilityContracts.ts +40 -0
  98. package/src/operations/contracts/observabilityPorts.ts +30 -0
  99. package/src/operations/contracts/ports.ts +22 -0
  100. package/src/operations/errors.ts +6 -0
  101. package/src/operations/index.ts +9 -0
  102. package/src/operations/schemas.ts +122 -0
  103. package/src/operations/server/adminAlertsHandlers.ts +61 -0
  104. package/src/operations/server/adminPipelineHandlers.ts +73 -0
  105. package/src/operations/server/adminSettingsHandlers.ts +114 -0
  106. package/src/operations/server/controlPlaneHandler.ts +56 -0
  107. package/src/operations/server/controlPlaneStore.ts +150 -0
  108. package/src/operations/server/controlPlaneWatchdog.ts +28 -0
  109. package/src/operations/server/index.ts +12 -0
  110. package/src/operations/server/migrations.ts +7 -0
  111. package/src/operations/server/platformWatchdogDecision.ts +93 -0
  112. package/src/operations/server/platformWatchdogDeliveryPolicy.ts +92 -0
  113. package/src/operations/server/platformWatchdogQueue.ts +96 -0
  114. package/src/operations/server/platformWatchdogService.ts +209 -0
  115. package/src/operations/validation.ts +3 -0
  116. package/src/portability/filesystemObjects.ts +138 -0
  117. package/src/portability/index.ts +12 -0
  118. package/src/portability/objects.ts +160 -0
  119. package/src/portability/postgresRows.ts +169 -0
  120. package/src/scheduler/advisoryLock.ts +20 -5
  121. package/src/scheduler/nodeScheduler.ts +28 -8
@@ -0,0 +1,12 @@
1
+ import { type TransferObjectStore } from "./objects.js";
2
+ /**
3
+ * Private local transfer store, exclusively controlled by its owner process/account.
4
+ * Metadata and bytes publish together through one directory rename. Concurrent use through
5
+ * this adapter is supported; hostile same-UID filesystem mutation is outside that guarantee.
6
+ */
7
+ export declare function createFilesystemTransferObjects(options: {
8
+ root: string;
9
+ maximumObjectBytes: number;
10
+ /** Host label for this filesystem owner's private-read policy; other ACLs refuse. */
11
+ access: string;
12
+ }): Promise<TransferObjectStore>;
@@ -0,0 +1,191 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { lstat, mkdir, open, realpath, rename, rm } from "node:fs/promises";
4
+ import { dirname, isAbsolute, join, resolve } from "node:path";
5
+ import { encodeTransferObject, normalizeObjectTransferError, ObjectTransferError, validateTransferObject, } from "./objects.js";
6
+ // Covers bounded metadata JSON in UTF-8 plus JSON-escaped bounded identifiers.
7
+ const maximumDescriptorBytes = 262144;
8
+ /**
9
+ * Private local transfer store, exclusively controlled by its owner process/account.
10
+ * Metadata and bytes publish together through one directory rename. Concurrent use through
11
+ * this adapter is supported; hostile same-UID filesystem mutation is outside that guarantee.
12
+ */
13
+ export async function createFilesystemTransferObjects(options) {
14
+ if (!isAbsolute(options.root) || !Number.isSafeInteger(options.maximumObjectBytes) || options.maximumObjectBytes < 0
15
+ || typeof options.access !== "string" || !options.access.trim()) {
16
+ throw new ObjectTransferError("invalid_input");
17
+ }
18
+ const root = resolve(options.root), maximum = options.maximumObjectBytes, access = options.access;
19
+ try {
20
+ if (await realpath(dirname(root)) !== dirname(root))
21
+ throw new ObjectTransferError("denied");
22
+ await mkdir(root, { mode: 0o700 }).catch(error => { if (errno(error) !== "EEXIST")
23
+ throw error; });
24
+ await privateDirectory(root, true);
25
+ }
26
+ catch (error) {
27
+ throw filesystemFailure(error);
28
+ }
29
+ const location = (key) => join(root, createHash("sha256").update(JSON.stringify(key)).digest("hex"));
30
+ return {
31
+ async read(key, expectedVersion) {
32
+ let body;
33
+ let published = false;
34
+ try {
35
+ await privateDirectory(root, true);
36
+ const directory = location(key);
37
+ await privateDirectory(directory);
38
+ published = true;
39
+ const metadata = await openPrivateFile(join(directory, "metadata.json"));
40
+ let object;
41
+ try {
42
+ if ((await metadata.stat()).size > maximumDescriptorBytes)
43
+ throw new ObjectTransferError("integrity_conflict");
44
+ try {
45
+ object = JSON.parse(await metadata.readFile("utf8"));
46
+ }
47
+ catch {
48
+ throw new ObjectTransferError("integrity_conflict");
49
+ }
50
+ }
51
+ finally {
52
+ await metadata.close();
53
+ }
54
+ validateTransferObject(object);
55
+ if (object.access !== access)
56
+ throw new ObjectTransferError("denied");
57
+ if (object.key !== key || (expectedVersion !== undefined && object.version !== expectedVersion))
58
+ throw new ObjectTransferError("version_conflict");
59
+ if (object.bytes > maximum)
60
+ throw new ObjectTransferError("invalid_input");
61
+ body = await openPrivateFile(join(directory, "bytes"));
62
+ if ((await body.stat()).size !== object.bytes)
63
+ throw new ObjectTransferError("integrity_conflict");
64
+ await body.close();
65
+ body = undefined;
66
+ // Open lazily: descriptor refusal must not leave an unconsumed stream holding a file.
67
+ return { object, body: (async function* () {
68
+ let opened;
69
+ try {
70
+ await privateDirectory(root, true);
71
+ await privateDirectory(directory);
72
+ opened = await openPrivateFile(join(directory, "bytes"));
73
+ if ((await opened.stat()).size !== object.bytes)
74
+ throw new ObjectTransferError("integrity_conflict");
75
+ for await (const chunk of opened.createReadStream({ autoClose: false }))
76
+ yield Buffer.from(chunk);
77
+ }
78
+ catch (error) {
79
+ throw errno(error) === "ENOENT" ? new ObjectTransferError("integrity_conflict") : filesystemFailure(error);
80
+ }
81
+ finally {
82
+ await opened?.close();
83
+ }
84
+ })() };
85
+ }
86
+ catch (error) {
87
+ await body?.close();
88
+ if (published && errno(error) === "ENOENT")
89
+ throw new ObjectTransferError("integrity_conflict");
90
+ throw filesystemFailure(error);
91
+ }
92
+ },
93
+ async createIfAbsent(object, bytes) {
94
+ let temporary;
95
+ try {
96
+ const encoded = Buffer.from(encodeTransferObject(object));
97
+ if (encoded.byteLength > maximumDescriptorBytes)
98
+ throw new ObjectTransferError("invalid_input");
99
+ if (object.access !== access)
100
+ throw new ObjectTransferError("denied");
101
+ const key = object.key;
102
+ if (object.bytes > maximum || bytes.byteLength !== object.bytes
103
+ || createHash("sha256").update(bytes).digest("hex") !== object.sha256)
104
+ throw new ObjectTransferError("integrity_conflict");
105
+ await privateDirectory(root, true);
106
+ temporary = join(root, `.partial-${randomUUID()}`);
107
+ await mkdir(temporary, { mode: 0o700 });
108
+ await writePrivateFile(join(temporary, "metadata.json"), encoded);
109
+ await writePrivateFile(join(temporary, "bytes"), bytes);
110
+ const directoryHandle = await open(temporary, constants.O_RDONLY | constants.O_NOFOLLOW);
111
+ try {
112
+ await directoryHandle.sync();
113
+ }
114
+ finally {
115
+ await directoryHandle.close();
116
+ }
117
+ try {
118
+ // Existing published objects are nonempty directories, so rename cannot replace them.
119
+ // Check also refuses an empty directory or symlink occupying a target key.
120
+ await lstat(location(key));
121
+ throw new ObjectTransferError("version_conflict");
122
+ }
123
+ catch (error) {
124
+ if (errno(error) !== "ENOENT")
125
+ throw error;
126
+ }
127
+ await rename(temporary, location(key));
128
+ temporary = undefined;
129
+ const rootHandle = await open(root, constants.O_RDONLY | constants.O_NOFOLLOW);
130
+ try {
131
+ await rootHandle.sync();
132
+ }
133
+ finally {
134
+ await rootHandle.close();
135
+ }
136
+ }
137
+ catch (error) {
138
+ throw filesystemFailure(error);
139
+ }
140
+ finally {
141
+ if (temporary)
142
+ await rm(temporary, { recursive: true, force: true });
143
+ }
144
+ },
145
+ };
146
+ }
147
+ async function privateDirectory(path, storeRoot = false) {
148
+ const stat = await lstat(path).catch(error => {
149
+ if (storeRoot && errno(error) === "ENOENT")
150
+ throw new ObjectTransferError("unavailable");
151
+ throw error;
152
+ });
153
+ if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o777) !== 0o700 || stat.uid !== process.getuid?.()) {
154
+ throw new ObjectTransferError("denied");
155
+ }
156
+ }
157
+ async function openPrivateFile(path) {
158
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
159
+ try {
160
+ const stat = await handle.stat();
161
+ if (!stat.isFile() || stat.nlink !== 1 || (stat.mode & 0o777) !== 0o600 || stat.uid !== process.getuid?.())
162
+ throw new ObjectTransferError("denied");
163
+ return handle;
164
+ }
165
+ catch (error) {
166
+ await handle.close();
167
+ throw error;
168
+ }
169
+ }
170
+ async function writePrivateFile(path, bytes) {
171
+ const handle = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
172
+ try {
173
+ await handle.writeFile(bytes);
174
+ await handle.sync();
175
+ }
176
+ finally {
177
+ await handle.close();
178
+ }
179
+ }
180
+ function errno(error) { return error?.code; }
181
+ function filesystemFailure(error) {
182
+ if (error instanceof ObjectTransferError)
183
+ return error;
184
+ if (errno(error) === "ENOENT")
185
+ return new ObjectTransferError("absent");
186
+ if (["EACCES", "EPERM", "ELOOP"].includes(errno(error) ?? ""))
187
+ return new ObjectTransferError("denied");
188
+ if (["EEXIST", "ENOTEMPTY"].includes(errno(error) ?? ""))
189
+ return new ObjectTransferError("version_conflict");
190
+ return normalizeObjectTransferError(error);
191
+ }
@@ -0,0 +1,5 @@
1
+ export { copyTransferObjects, ObjectTransferError } from "./objects.js";
2
+ export type { ObjectTransferFailure, TransferObject, TransferObjectRead, TransferObjectReader, TransferObjectStore, TransferObjectInventory, } from "./objects.js";
3
+ export { createFilesystemTransferObjects } from "./filesystemObjects.js";
4
+ export { captureCompatiblePostgresRows, restoreCompatiblePostgresRows, PostgresTransferError } from "./postgresRows.js";
5
+ export type { CompatiblePostgresTable, TransferPostgresQuery, CompatiblePostgresSnapshot, CompatiblePostgresTarget, CompatiblePostgresPacket, PostgresTransferFailure, } from "./postgresRows.js";
@@ -0,0 +1,4 @@
1
+ // Server-only, opt-in data movement. No executor, route or handover is activated.
2
+ export { copyTransferObjects, ObjectTransferError } from "./objects.js";
3
+ export { createFilesystemTransferObjects } from "./filesystemObjects.js";
4
+ export { captureCompatiblePostgresRows, restoreCompatiblePostgresRows, PostgresTransferError } from "./postgresRows.js";
@@ -0,0 +1,63 @@
1
+ export type ObjectTransferFailure = "absent" | "denied" | "unavailable" | "version_conflict" | "integrity_conflict" | "invalid_input";
2
+ /** Adapters distinguish missing data from permission and availability failures. */
3
+ export declare class ObjectTransferError extends Error {
4
+ readonly code: ObjectTransferFailure;
5
+ constructor(code: ObjectTransferFailure);
6
+ }
7
+ export interface TransferObject {
8
+ key: string;
9
+ /** Logical immutable source version; never a claim about a target provider's native version ID. */
10
+ version: string;
11
+ bytes: number;
12
+ sha256: string;
13
+ metadata: Readonly<Record<string, string>>;
14
+ /** An explicit access classification, never inferred from a public URL. */
15
+ access: string;
16
+ lineage?: {
17
+ sourceId: string;
18
+ snapshotId: string;
19
+ sourceVersion: string;
20
+ };
21
+ }
22
+ export interface TransferObjectRead {
23
+ object: TransferObject;
24
+ body: AsyncIterable<Uint8Array>;
25
+ }
26
+ export interface TransferObjectReader {
27
+ /** Must read the requested version or refuse, never silently read a newer one. */
28
+ read(key: string, expectedVersion?: string): Promise<TransferObjectRead>;
29
+ }
30
+ export interface TransferObjectStore extends TransferObjectReader {
31
+ /** Atomic publication; an existing key must refuse with version_conflict. */
32
+ createIfAbsent(object: TransferObject, bytes: Uint8Array): Promise<void>;
33
+ }
34
+ export interface TransferObjectInventory {
35
+ sourceId: string;
36
+ /** Host prerequisite: complete inventory and stable versioned reads, or a held write fence. */
37
+ snapshot: {
38
+ kind: "immutable" | "quiesced";
39
+ id: string;
40
+ };
41
+ objects: readonly TransferObject[];
42
+ }
43
+ /** Canonical descriptor encoding also preserves metadata independent of property order. */
44
+ export declare function encodeTransferObject(object: TransferObject): string;
45
+ export declare function validateTransferObject(object: TransferObject): void;
46
+ /** Bounded in-memory object copy; ceilings are explicit and checked before any destination mutation. */
47
+ export declare function copyTransferObjects(input: {
48
+ inventory: TransferObjectInventory;
49
+ source: TransferObjectReader;
50
+ destination: TransferObjectStore;
51
+ accessMapping: Readonly<Record<string, string>>;
52
+ maximumObjectBytes: number;
53
+ maximumTotalBytes: number;
54
+ /** Called only after destination descriptor and bytes have been read back successfully. */
55
+ checkpoint?: (object: TransferObject) => Promise<void>;
56
+ }): Promise<{
57
+ copied: number;
58
+ replayed: number;
59
+ verifiedKeys: string[];
60
+ }>;
61
+ /** Read exactly the declared bytes and hash; never trust a successful HEAD alone. */
62
+ export declare function verifyRead(read: TransferObjectRead, expected: TransferObject): Promise<Uint8Array>;
63
+ export declare function normalizeObjectTransferError(error: unknown): ObjectTransferError;
@@ -0,0 +1,138 @@
1
+ import { createHash } from "node:crypto";
2
+ /** Adapters distinguish missing data from permission and availability failures. */
3
+ export class ObjectTransferError extends Error {
4
+ code;
5
+ constructor(code) {
6
+ super(`object_transfer_${code}`);
7
+ this.code = code;
8
+ this.name = "ObjectTransferError";
9
+ }
10
+ }
11
+ /** Canonical descriptor encoding also preserves metadata independent of property order. */
12
+ export function encodeTransferObject(object) {
13
+ validateTransferObject(object);
14
+ return JSON.stringify({
15
+ key: object.key, version: object.version, bytes: object.bytes, sha256: object.sha256,
16
+ metadata: Object.fromEntries(Object.entries(object.metadata).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)),
17
+ access: object.access,
18
+ ...(object.lineage ? { lineage: { sourceId: object.lineage.sourceId, snapshotId: object.lineage.snapshotId, sourceVersion: object.lineage.sourceVersion } } : {}),
19
+ });
20
+ }
21
+ export function validateTransferObject(object) {
22
+ if (!object || !text(object.key, 4096) || !text(object.version, 1024)
23
+ || !Number.isSafeInteger(object.bytes) || object.bytes < 0 || !/^[a-f0-9]{64}$/.test(object.sha256)
24
+ || !text(object.access, 1024) || !object.metadata || typeof object.metadata !== "object" || Array.isArray(object.metadata)
25
+ || Object.entries(object.metadata).some(([key, value]) => !text(key, 1024) || typeof value !== "string" || value.length > 8192)
26
+ || JSON.stringify(object.metadata).length > 65536
27
+ || (object.lineage !== undefined && (!object.lineage || !text(object.lineage.sourceId, 1024)
28
+ || !text(object.lineage.snapshotId, 1024) || !text(object.lineage.sourceVersion, 1024)))) {
29
+ throw new ObjectTransferError("invalid_input");
30
+ }
31
+ }
32
+ /** Bounded in-memory object copy; ceilings are explicit and checked before any destination mutation. */
33
+ export async function copyTransferObjects(input) {
34
+ const { inventory } = input;
35
+ if (!inventory || !text(inventory.sourceId, 1024) || !inventory.snapshot
36
+ || !["immutable", "quiesced"].includes(inventory.snapshot.kind) || !text(inventory.snapshot.id, 1024)
37
+ || !Array.isArray(inventory.objects) || !input.accessMapping
38
+ || !ceiling(input.maximumObjectBytes) || !ceiling(input.maximumTotalBytes))
39
+ throw new ObjectTransferError("invalid_input");
40
+ // Snapshot inputs before the first await; a caller cannot change the plan during a copy.
41
+ const sourceId = inventory.sourceId, snapshotId = inventory.snapshot.id;
42
+ const keys = new Set();
43
+ let total = 0;
44
+ const plan = inventory.objects.map(object => {
45
+ const source = JSON.parse(encodeTransferObject(object));
46
+ const access = Object.hasOwn(input.accessMapping, source.access) ? input.accessMapping[source.access] : undefined;
47
+ total += source.bytes;
48
+ if (keys.has(source.key) || !text(access, 1024) || source.bytes > input.maximumObjectBytes
49
+ || !Number.isSafeInteger(total) || total > input.maximumTotalBytes)
50
+ throw new ObjectTransferError("invalid_input");
51
+ keys.add(source.key);
52
+ const target = { ...source, access, lineage: { sourceId, snapshotId, sourceVersion: source.version } };
53
+ return { source, target };
54
+ });
55
+ const result = { copied: 0, replayed: 0, verifiedKeys: [] };
56
+ for (const { source, target } of plan) {
57
+ let existing;
58
+ try {
59
+ existing = await input.destination.read(target.key);
60
+ }
61
+ catch (error) {
62
+ if (!failure(error, "absent"))
63
+ throw normalizeObjectTransferError(error);
64
+ }
65
+ if (existing) {
66
+ await verifyRead(existing, target);
67
+ result.replayed++;
68
+ }
69
+ else {
70
+ let read;
71
+ try {
72
+ read = await input.source.read(source.key, source.version);
73
+ }
74
+ catch (error) {
75
+ throw normalizeObjectTransferError(error);
76
+ }
77
+ const bytes = await verifyRead(read, source);
78
+ let writeFailure;
79
+ try {
80
+ await input.destination.createIfAbsent(target, bytes);
81
+ }
82
+ catch (error) {
83
+ // A competing writer or lost write acknowledgement still requires exact readback.
84
+ if (!failure(error, "version_conflict") && !failure(error, "unavailable"))
85
+ throw normalizeObjectTransferError(error);
86
+ writeFailure = normalizeObjectTransferError(error);
87
+ }
88
+ try {
89
+ await verifyRead(await input.destination.read(target.key), target);
90
+ }
91
+ catch (error) {
92
+ throw failure(error, "absent") && writeFailure ? writeFailure : normalizeObjectTransferError(error);
93
+ }
94
+ if (writeFailure)
95
+ result.replayed++;
96
+ else
97
+ result.copied++;
98
+ }
99
+ await input.checkpoint?.(JSON.parse(encodeTransferObject(target)));
100
+ result.verifiedKeys.push(target.key);
101
+ }
102
+ return result;
103
+ }
104
+ /** Read exactly the declared bytes and hash; never trust a successful HEAD alone. */
105
+ export async function verifyRead(read, expected) {
106
+ if (encodeTransferObject(read.object) !== encodeTransferObject(expected))
107
+ throw new ObjectTransferError("version_conflict");
108
+ const chunks = [];
109
+ const hash = createHash("sha256");
110
+ let bytes = 0;
111
+ try {
112
+ for await (const chunk of read.body) {
113
+ if (!(chunk instanceof Uint8Array))
114
+ throw new ObjectTransferError("integrity_conflict");
115
+ bytes += chunk.byteLength;
116
+ if (bytes > expected.bytes)
117
+ throw new ObjectTransferError("integrity_conflict");
118
+ const copy = Buffer.from(chunk);
119
+ chunks.push(copy);
120
+ hash.update(copy);
121
+ }
122
+ }
123
+ catch (error) {
124
+ throw normalizeObjectTransferError(error);
125
+ }
126
+ if (bytes !== expected.bytes || hash.digest("hex") !== expected.sha256)
127
+ throw new ObjectTransferError("integrity_conflict");
128
+ return Buffer.concat(chunks, bytes);
129
+ }
130
+ export function normalizeObjectTransferError(error) {
131
+ return error instanceof ObjectTransferError ? error : new ObjectTransferError("unavailable");
132
+ }
133
+ function text(value, maximum) {
134
+ return typeof value === "string" && value.length > 0 && value.length <= maximum
135
+ && !Array.from(value).some(character => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127);
136
+ }
137
+ function ceiling(value) { return Number.isSafeInteger(value) && value >= 0; }
138
+ function failure(error, code) { return error instanceof ObjectTransferError && error.code === code; }
@@ -0,0 +1,64 @@
1
+ /** A finite compatible-row utility, not a complete-store migration profile. */
2
+ declare const TABLES: readonly ["clients", "subscriptions", "subscription_events", "subscription_pause_windows", "subscription_renewal_terms", "subscription_renewal_term_lines", "subscription_renewal_operations", "subscription_cycles", "commerce_orders", "commerce_order_items", "commerce_settlement_intents", "commerce_settlement_transitions", "payment_truth_events", "payment_truth_reconciliation_evidence", "subscription_renewal_outcomes", "outbox_events", "invoicing_duties", "invoicing_fact_revisions", "invoicing_effect_receipts", "invoicing_capture_faults"];
3
+ export type CompatiblePostgresTable = typeof TABLES[number];
4
+ export interface TransferPostgresQuery {
5
+ query<Row extends Record<string, unknown> = Record<string, unknown>>(sql: string, values?: readonly unknown[]): Promise<{
6
+ rows: Row[];
7
+ }>;
8
+ }
9
+ export interface CompatiblePostgresSnapshot {
10
+ /** Own one read-only, repeatable-read transaction for the complete callback. */
11
+ withSnapshot<T>(work: (db: TransferPostgresQuery) => Promise<T>): Promise<T>;
12
+ }
13
+ export interface CompatiblePostgresTarget {
14
+ /** Own one transaction; commit only after the complete callback succeeds. */
15
+ withTransaction<T>(work: (db: TransferPostgresQuery) => Promise<T>): Promise<T>;
16
+ }
17
+ type Column = {
18
+ name: string;
19
+ type: string;
20
+ key: boolean;
21
+ };
22
+ type TableRows = {
23
+ table: CompatiblePostgresTable;
24
+ columns: Column[];
25
+ rows: string[];
26
+ };
27
+ export interface CompatiblePostgresPacket {
28
+ format: "openlup.compatible-rows.v1";
29
+ sourceId: string;
30
+ /** Opaque selected-catalogue identity supplied by the validated host capability. */
31
+ catalogueId: string;
32
+ tables: TableRows[];
33
+ sha256: string;
34
+ }
35
+ export type PostgresTransferFailure = "invalid_input" | "unsupported_table" | "schema_conflict" | "insert_trigger_requires_owner" | "target_conflict" | "limit_exceeded" | "database_unavailable";
36
+ export declare class PostgresTransferError extends Error {
37
+ readonly code: PostgresTransferFailure;
38
+ readonly table?: string | undefined;
39
+ constructor(code: PostgresTransferFailure, table?: string | undefined);
40
+ }
41
+ /** Data only: no schema, role, grant, migration ledger, control or provider-internal copy. */
42
+ export declare function captureCompatiblePostgresRows(input: {
43
+ source: CompatiblePostgresSnapshot;
44
+ sourceId: string;
45
+ catalogueId: string;
46
+ tables: readonly CompatiblePostgresTable[];
47
+ maximumRows: number;
48
+ maximumBytes: number;
49
+ }): Promise<CompatiblePostgresPacket>;
50
+ /**
51
+ * Atomic insert and exact replay. A differing row refuses; there is no upsert,
52
+ * deletion scan, status reset or permission change. Existing parent rows remain
53
+ * prerequisites, and this utility never authorizes target effect execution.
54
+ */
55
+ export declare function restoreCompatiblePostgresRows(input: {
56
+ target: CompatiblePostgresTarget;
57
+ catalogueId: string;
58
+ packet: CompatiblePostgresPacket;
59
+ }): Promise<{
60
+ inserted: number;
61
+ replayed: number;
62
+ sha256: string;
63
+ }>;
64
+ export {};
@@ -0,0 +1,182 @@
1
+ import { createHash } from "node:crypto";
2
+ /** A finite compatible-row utility, not a complete-store migration profile. */
3
+ const TABLES = [
4
+ "clients", "subscriptions", "subscription_events", "subscription_pause_windows",
5
+ "subscription_renewal_terms", "subscription_renewal_term_lines", "subscription_renewal_operations",
6
+ "subscription_cycles", "commerce_orders", "commerce_order_items", "commerce_settlement_intents",
7
+ "commerce_settlement_transitions", "payment_truth_events", "payment_truth_reconciliation_evidence",
8
+ "subscription_renewal_outcomes", "outbox_events", "invoicing_duties", "invoicing_fact_revisions",
9
+ "invoicing_effect_receipts", "invoicing_capture_faults",
10
+ ];
11
+ export class PostgresTransferError extends Error {
12
+ code;
13
+ table;
14
+ constructor(code, table) {
15
+ super(`postgres_transfer_${code}${table ? `:${table}` : ""}`);
16
+ this.code = code;
17
+ this.table = table;
18
+ this.name = "PostgresTransferError";
19
+ }
20
+ }
21
+ const LIMIT_ROWS = 10000, LIMIT_BYTES = 64 * 1024 * 1024;
22
+ /** Data only: no schema, role, grant, migration ledger, control or provider-internal copy. */
23
+ export async function captureCompatiblePostgresRows(input) {
24
+ const { sourceId, catalogueId, maximumRows, maximumBytes } = input;
25
+ if (!identity(sourceId) || !identity(catalogueId) || !ceiling(maximumRows, LIMIT_ROWS) || !ceiling(maximumBytes, LIMIT_BYTES))
26
+ fail("invalid_input");
27
+ const names = tableNames(input.tables);
28
+ try {
29
+ const tables = await input.source.withSnapshot(async (db) => {
30
+ const mode = (await db.query("SELECT current_setting('transaction_read_only') AS readonly, current_setting('transaction_isolation') AS isolation")).rows[0];
31
+ if (mode?.readonly !== "on" || mode.isolation !== "repeatable read")
32
+ fail("invalid_input");
33
+ await db.query("SET LOCAL TimeZone = 'UTC'");
34
+ let count = 0, bytes = 0;
35
+ const result = [];
36
+ for (const table of names) {
37
+ const columns = await inspect(db, table);
38
+ const order = columns.filter(column => column.key).map(column => quoted(column.name)).join(",");
39
+ // PostgreSQL serializes bigint, numeric, bytea and timestamp values.
40
+ // Retain that JSON text unchanged; a JS numeric round trip is lossy.
41
+ const rows = (await db.query(`SELECT to_jsonb(t)::text AS value FROM public.${quoted(table)} t ORDER BY ${order} LIMIT $1`, [maximumRows + 1])).rows.map(row => row.value);
42
+ for (const row of rows) {
43
+ count++;
44
+ bytes += Buffer.byteLength(row);
45
+ }
46
+ if (count > maximumRows || bytes > maximumBytes)
47
+ fail("limit_exceeded", table);
48
+ result.push({ table, columns, rows });
49
+ }
50
+ return result;
51
+ });
52
+ const packet = { format: "openlup.compatible-rows.v1", sourceId, catalogueId, tables };
53
+ return { ...packet, sha256: digest(packet) };
54
+ }
55
+ catch (error) {
56
+ throw redacted(error);
57
+ }
58
+ }
59
+ /**
60
+ * Atomic insert and exact replay. A differing row refuses; there is no upsert,
61
+ * deletion scan, status reset or permission change. Existing parent rows remain
62
+ * prerequisites, and this utility never authorizes target effect execution.
63
+ */
64
+ export async function restoreCompatiblePostgresRows(input) {
65
+ // Own immutable input bytes throughout async validation and writes.
66
+ let packet;
67
+ try {
68
+ packet = JSON.parse(JSON.stringify(input.packet));
69
+ validatePacket(packet);
70
+ }
71
+ catch (error) {
72
+ if (error instanceof PostgresTransferError)
73
+ throw error;
74
+ fail("invalid_input");
75
+ }
76
+ if (packet.catalogueId !== input.catalogueId)
77
+ fail("schema_conflict");
78
+ try {
79
+ return await input.target.withTransaction(async (db) => {
80
+ const mode = (await db.query("SELECT current_setting('session_replication_role') AS mode")).rows[0];
81
+ if (mode?.mode !== "origin")
82
+ fail("invalid_input");
83
+ await db.query("SET LOCAL TimeZone = 'UTC'");
84
+ // Serializes writers and prevents DDL between inspection and inserts.
85
+ // Fixed lock ordering avoids deadlock between overlapping packet imports.
86
+ for (const table of [...packet.tables].sort((a, b) => a.table.localeCompare(b.table))) {
87
+ await db.query(`LOCK TABLE public.${quoted(table.table)} IN SHARE ROW EXCLUSIVE MODE`);
88
+ }
89
+ for (const table of packet.tables) {
90
+ if (JSON.stringify(await inspect(db, table.table)) !== JSON.stringify(table.columns))
91
+ fail("schema_conflict", table.table);
92
+ const triggers = (await db.query(`SELECT 1 FROM pg_catalog.pg_trigger
93
+ WHERE tgrelid=$1::regclass AND NOT tgisinternal AND (tgtype & 4) <> 0 AND tgenabled IN ('O','A')`, [`public.${table.table}`])).rows;
94
+ if (triggers.length)
95
+ fail("insert_trigger_requires_owner", table.table);
96
+ }
97
+ let inserted = 0, replayed = 0;
98
+ for (const table of packet.tables) {
99
+ const name = `public.${quoted(table.table)}`;
100
+ const columns = table.columns.map(column => quoted(column.name)).join(",");
101
+ const keys = table.columns.filter(column => column.key).map(column => `t.${quoted(column.name)} IS NOT DISTINCT FROM p.${quoted(column.name)}`).join(" AND ");
102
+ for (const row of table.rows) {
103
+ const write = await db.query(`INSERT INTO ${name} (${columns}) SELECT ${columns} FROM jsonb_populate_record(NULL::${name}, $1::jsonb)
104
+ ON CONFLICT DO NOTHING RETURNING 1 AS inserted`, [row]);
105
+ const read = await db.query(`SELECT to_jsonb(t) = $1::jsonb AS identical FROM ${name} t,
106
+ jsonb_populate_record(NULL::${name}, $1::jsonb) p WHERE ${keys}`, [row]);
107
+ if (read.rows.length !== 1 || read.rows[0]?.identical !== true)
108
+ fail("target_conflict", table.table);
109
+ if (write.rows.length)
110
+ inserted++;
111
+ else
112
+ replayed++;
113
+ }
114
+ }
115
+ return { inserted, replayed, sha256: packet.sha256 };
116
+ });
117
+ }
118
+ catch (error) {
119
+ throw redacted(error);
120
+ }
121
+ }
122
+ async function inspect(db, table) {
123
+ const rows = (await db.query(`SELECT a.attname AS name,
124
+ pg_catalog.format_type(a.atttypid,a.atttypmod) AS type, COALESCE(a.attnum = ANY(i.indkey),false) AS key,
125
+ a.attgenerated AS generated, a.attidentity AS identity
126
+ FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_class c ON c.oid=a.attrelid
127
+ LEFT JOIN pg_catalog.pg_index i ON i.indrelid=c.oid AND i.indisprimary
128
+ WHERE a.attrelid=$1::regclass AND c.relkind='r' AND a.attnum>0 AND NOT a.attisdropped ORDER BY a.attnum`, [`public.${table}`])).rows;
129
+ if (!rows.length || !rows.some(row => row.key) || rows.some(row => row.generated || row.identity || !identifier(row.name)))
130
+ fail("schema_conflict", table);
131
+ return rows.map(({ name, type, key }) => ({ name, type, key }));
132
+ }
133
+ function tableNames(tables) {
134
+ if (!Array.isArray(tables) || !tables.length || new Set(tables).size !== tables.length)
135
+ fail("invalid_input");
136
+ for (const table of tables)
137
+ if (!TABLES.includes(table))
138
+ fail("unsupported_table");
139
+ return TABLES.filter(table => tables.includes(table));
140
+ }
141
+ function validatePacket(packet) {
142
+ if (!packet || packet.format !== "openlup.compatible-rows.v1" || !identity(packet.sourceId) || !identity(packet.catalogueId)
143
+ || !Array.isArray(packet.tables) || !/^[a-f0-9]{64}$/.test(packet.sha256))
144
+ fail("invalid_input");
145
+ const { sha256: _hash, ...payload } = packet;
146
+ if (digest(payload) !== packet.sha256)
147
+ fail("invalid_input");
148
+ const names = tableNames(packet.tables.map(table => table.table));
149
+ if (names.some((name, index) => name !== packet.tables[index]?.table))
150
+ fail("invalid_input");
151
+ let count = 0, bytes = 0;
152
+ for (const table of packet.tables) {
153
+ if (!Array.isArray(table.columns) || !table.columns.length || !table.columns.some(column => column.key)
154
+ || table.columns.some(column => !identifier(column.name) || !identity(column.type) || typeof column.key !== "boolean")
155
+ || new Set(table.columns.map(column => column.name)).size !== table.columns.length || !Array.isArray(table.rows))
156
+ fail("invalid_input");
157
+ for (const row of table.rows) {
158
+ if (typeof row !== "string")
159
+ fail("invalid_input");
160
+ // Inspect only field names. Never stringify parsed values back to SQL.
161
+ const parsed = JSON.parse(row);
162
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)
163
+ || Object.keys(parsed).sort().join("\0") !== table.columns.map(column => column.name).sort().join("\0"))
164
+ fail("invalid_input");
165
+ count++;
166
+ bytes += Buffer.byteLength(row);
167
+ }
168
+ }
169
+ if (count > LIMIT_ROWS || bytes > LIMIT_BYTES)
170
+ fail("limit_exceeded");
171
+ }
172
+ function digest(value) { return createHash("sha256").update(JSON.stringify(value)).digest("hex"); }
173
+ function identifier(value) { return /^[a-z][a-z0-9_]{0,62}$/.test(value); }
174
+ function quoted(value) { if (!identifier(value))
175
+ fail("invalid_input"); return `"${value}"`; }
176
+ function identity(value) {
177
+ return typeof value === "string" && value.length > 0 && value.length <= 1024
178
+ && !Array.from(value).some(character => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127);
179
+ }
180
+ function ceiling(value, maximum) { return Number.isSafeInteger(value) && value > 0 && value <= maximum; }
181
+ function fail(code, table) { throw new PostgresTransferError(code, table); }
182
+ function redacted(error) { return error instanceof PostgresTransferError ? error : new PostgresTransferError("database_unavailable"); }