@volter/world-core 2.0.36 → 3.0.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 (205) hide show
  1. package/README.md +4 -5
  2. package/app-route.cjs +12 -6
  3. package/app-route.d.cts +1 -1
  4. package/dist/app-route.cjs +12 -6
  5. package/dist/app-route.d.cts +1 -1
  6. package/dist/generated/pack-facts.json +1410 -3069
  7. package/dist/inject.cjs +64 -9
  8. package/dist/pack-facts.cjs +44 -0
  9. package/dist/src/actions.d.ts +3 -3
  10. package/dist/src/actions.js +22 -16
  11. package/dist/src/ancestry.d.ts +14 -2
  12. package/dist/src/ancestry.js +92 -2
  13. package/dist/src/anthropic-wire.d.ts +39 -0
  14. package/dist/src/anthropic-wire.js +136 -0
  15. package/dist/src/bytes.d.ts +7 -0
  16. package/dist/src/bytes.js +35 -0
  17. package/dist/src/changeset.d.ts +1 -1
  18. package/dist/src/changeset.js +0 -0
  19. package/dist/src/clickhouse/index.d.ts +3 -0
  20. package/dist/src/clickhouse/index.js +6 -0
  21. package/dist/src/clickhouse/sql.d.ts +233 -0
  22. package/dist/src/clickhouse/sql.js +4329 -0
  23. package/dist/src/clickhouse/types.d.ts +18 -0
  24. package/dist/src/clickhouse/types.js +47 -0
  25. package/dist/src/clickhouse/values.d.ts +146 -0
  26. package/dist/src/clickhouse/values.js +858 -0
  27. package/dist/src/client-bundle.js +2 -3
  28. package/dist/src/cors.d.ts +15 -0
  29. package/dist/src/cors.js +31 -0
  30. package/dist/src/derived-core.d.ts +487 -24
  31. package/dist/src/derived-core.js +788 -144
  32. package/dist/src/derived-real.d.ts +13 -0
  33. package/dist/src/derived-real.js +518 -0
  34. package/dist/src/derived.d.ts +35 -1
  35. package/dist/src/derived.js +61 -9
  36. package/dist/src/emit.js +1 -2
  37. package/dist/src/events.d.ts +206 -0
  38. package/dist/src/events.js +341 -0
  39. package/dist/src/executor.d.ts +3 -0
  40. package/dist/src/executor.js +19 -2
  41. package/dist/src/file-response.d.ts +6 -0
  42. package/dist/src/file-response.js +30 -0
  43. package/dist/src/fork.js +3 -2
  44. package/dist/src/git/history.d.ts +7 -0
  45. package/dist/src/git/history.js +24 -0
  46. package/dist/src/git/index.d.ts +1 -0
  47. package/dist/src/git/index.js +1 -0
  48. package/dist/src/git/lfs.d.ts +28 -0
  49. package/dist/src/git/lfs.js +66 -0
  50. package/dist/src/git/objects.js +3 -8
  51. package/dist/src/git/smart-http.d.ts +3 -1
  52. package/dist/src/git/smart-http.js +67 -6
  53. package/dist/src/graphql-wire.d.ts +29 -0
  54. package/dist/src/graphql-wire.js +101 -0
  55. package/dist/src/grpc-wire.d.ts +67 -0
  56. package/dist/src/grpc-wire.js +170 -0
  57. package/dist/src/h2.d.ts +40 -0
  58. package/dist/src/h2.js +656 -0
  59. package/dist/src/head.d.ts +32 -3
  60. package/dist/src/head.js +161 -40
  61. package/dist/src/history.d.ts +1 -1
  62. package/dist/src/history.js +6 -6
  63. package/dist/src/hpack.json +1 -0
  64. package/dist/src/index.d.ts +64 -75
  65. package/dist/src/index.js +58 -101
  66. package/dist/src/log.js +28 -19
  67. package/dist/src/machines.d.ts +50 -0
  68. package/dist/src/machines.js +151 -0
  69. package/dist/src/managed-database.d.ts +86 -0
  70. package/dist/src/managed-database.js +283 -0
  71. package/dist/src/multipart.d.ts +11 -0
  72. package/dist/src/multipart.js +51 -0
  73. package/dist/src/observe.d.ts +15 -5
  74. package/dist/src/observe.js +23 -9
  75. package/dist/src/openai-wire.d.ts +108 -0
  76. package/dist/src/openai-wire.js +337 -0
  77. package/dist/src/pack-assets.d.ts +3 -4
  78. package/dist/src/pack-assets.js +15 -10
  79. package/dist/src/pack-fetch.d.ts +77 -0
  80. package/dist/src/pack-fetch.js +449 -0
  81. package/dist/src/pack-paths.d.ts +12 -0
  82. package/dist/src/pack-paths.js +86 -0
  83. package/dist/src/packRegistry.d.ts +69 -162
  84. package/dist/src/packRegistry.js +55 -20
  85. package/dist/src/people.d.ts +13 -0
  86. package/dist/src/people.js +18 -0
  87. package/dist/src/placeholder-image.d.ts +5 -0
  88. package/dist/src/placeholder-image.js +114 -0
  89. package/dist/src/protobuf.d.ts +28 -0
  90. package/dist/src/protobuf.js +332 -0
  91. package/dist/src/redis/engine.js +1 -1
  92. package/dist/src/request-scope.d.ts +1 -1
  93. package/dist/src/request-scope.js +6 -4
  94. package/dist/src/resource-blob.d.ts +5 -0
  95. package/dist/src/resource-blob.js +11 -0
  96. package/dist/src/runtime.d.ts +85 -0
  97. package/dist/src/runtime.js +104 -0
  98. package/dist/src/s3/wire.d.ts +60 -0
  99. package/dist/src/s3/wire.js +157 -0
  100. package/dist/src/scenario.d.ts +3 -0
  101. package/dist/src/scenario.js +2 -0
  102. package/dist/src/schema-sample.d.ts +1 -0
  103. package/dist/src/schema-sample.js +21 -0
  104. package/dist/src/sealed-box.d.ts +14 -0
  105. package/dist/src/sealed-box.js +225 -0
  106. package/dist/src/serve-http.d.ts +14 -0
  107. package/dist/src/serve-http.js +27 -3
  108. package/dist/src/serve.d.ts +6 -0
  109. package/dist/src/serve.js +69 -14
  110. package/dist/src/signing.d.ts +135 -0
  111. package/dist/src/signing.js +222 -0
  112. package/dist/src/sigv4.d.ts +48 -0
  113. package/dist/src/sigv4.js +167 -0
  114. package/dist/src/smtp.d.ts +16 -0
  115. package/dist/src/smtp.js +72 -0
  116. package/dist/src/sockets.d.ts +51 -0
  117. package/dist/src/sockets.js +90 -0
  118. package/dist/src/state-system.d.ts +1 -0
  119. package/dist/src/state-system.js +1 -1
  120. package/dist/src/storage.d.ts +1 -1
  121. package/dist/src/storage.js +3 -3
  122. package/dist/src/trace-context.js +1 -1
  123. package/dist/src/twin-fetch.d.ts +0 -7
  124. package/dist/src/twin-fetch.js +0 -14
  125. package/dist/src/vendor-call.d.ts +6 -0
  126. package/dist/src/vendor-call.js +41 -0
  127. package/dist/src/world-store.js +1 -1
  128. package/dist/vendor-hosts.cjs +36 -125
  129. package/dist/vendor-hosts.d.cts +8 -0
  130. package/generated/pack-facts.json +1410 -3069
  131. package/inject.cjs +64 -9
  132. package/pack-facts.cjs +44 -0
  133. package/package.json +17 -3
  134. package/src/actions.ts +23 -16
  135. package/src/ancestry.ts +74 -2
  136. package/src/anthropic-wire.ts +137 -0
  137. package/src/bytes.ts +42 -0
  138. package/src/changeset.ts +5 -5
  139. package/src/clickhouse/index.ts +6 -0
  140. package/src/clickhouse/sql.ts +3059 -0
  141. package/src/clickhouse/types.ts +44 -0
  142. package/src/clickhouse/values.ts +697 -0
  143. package/src/client-bundle.ts +2 -3
  144. package/src/cors.ts +34 -0
  145. package/src/derived-core.ts +1013 -146
  146. package/src/derived-real.ts +434 -0
  147. package/src/derived.ts +73 -3
  148. package/src/emit.ts +1 -2
  149. package/src/events.ts +449 -0
  150. package/src/executor.ts +24 -2
  151. package/src/file-response.ts +27 -0
  152. package/src/fork.ts +3 -2
  153. package/src/git/history.ts +19 -0
  154. package/src/git/index.ts +1 -0
  155. package/src/git/lfs.ts +67 -0
  156. package/src/git/objects.ts +3 -5
  157. package/src/git/smart-http.ts +56 -6
  158. package/src/graphql-wire.ts +106 -0
  159. package/src/grpc-wire.ts +159 -0
  160. package/src/h2.ts +627 -0
  161. package/src/head.ts +132 -41
  162. package/src/history.ts +6 -6
  163. package/src/hpack.json +1 -0
  164. package/src/index.ts +82 -329
  165. package/src/log.ts +27 -18
  166. package/src/machines.ts +151 -0
  167. package/src/managed-database.ts +299 -0
  168. package/src/multipart.ts +51 -0
  169. package/src/observe.ts +31 -15
  170. package/src/openai-wire.ts +371 -0
  171. package/src/pack-assets.ts +15 -11
  172. package/src/pack-fetch.ts +458 -0
  173. package/src/pack-paths.ts +72 -0
  174. package/src/packRegistry.ts +79 -167
  175. package/src/people.ts +31 -0
  176. package/src/placeholder-image.ts +88 -0
  177. package/src/protobuf.ts +251 -0
  178. package/src/redis/engine.ts +1 -1
  179. package/src/request-scope.ts +8 -4
  180. package/src/resource-blob.ts +13 -0
  181. package/src/runtime.ts +344 -0
  182. package/src/s3/wire.ts +172 -0
  183. package/src/scenario.ts +4 -0
  184. package/src/schema-sample.ts +24 -0
  185. package/src/sealed-box.ts +182 -0
  186. package/src/serve-http.ts +31 -3
  187. package/src/serve.ts +58 -14
  188. package/src/signing.ts +231 -0
  189. package/src/sigv4.ts +158 -0
  190. package/src/smtp.ts +76 -0
  191. package/src/sockets.ts +140 -0
  192. package/src/state-system.ts +2 -2
  193. package/src/storage.ts +3 -3
  194. package/src/trace-context.ts +1 -1
  195. package/src/twin-fetch.ts +0 -20
  196. package/src/vendor-call.ts +41 -0
  197. package/src/world-store.ts +1 -1
  198. package/vendor-hosts.cjs +36 -125
  199. package/vendor-hosts.d.cts +8 -0
  200. package/dist/src/mirror-shell.d.ts +0 -2
  201. package/dist/src/mirror-shell.js +0 -13
  202. package/dist/src/v1-removed.d.ts +0 -159
  203. package/dist/src/v1-removed.js +0 -124
  204. package/src/mirror-shell.ts +0 -15
  205. package/src/v1-removed.ts +0 -172
@@ -0,0 +1,151 @@
1
+ // The World's machine pool (architecture, "Other wires: … machines"): what runs a vendor's customer's image when the
2
+ // vendor is one that runs images (Fly's Machines). It is a platform provider enrolled through the kernel's door
3
+ // `POST /_twin/machine-pool`, recorded in the pack's own tree; a handler reaches it as `ctx.machines`. `docker` runs
4
+ // each machine as a local container with its internal ports published on loopback; `none` runs nothing; `fake`
5
+ // records what it was asked (capability verification). Nothing here runs at module scope, and `node:child_process`
6
+ // is reached only when a docker pool is asked to act.
7
+ import { nodeBuiltin } from './serve-http.ts';
8
+
9
+ export type MachineSpec = { name: string; image: string; env?: Record<string, string>; ports?: number[]; command?: string[] };
10
+ export type MachineRun = { ports: Array<{ internal: number; host: number }> };
11
+ /** A process started in a machine: its pid, and what it writes and how it ends, as they happen. */
12
+ export type MachineProcess = { pid: number; events: AsyncIterable<{ stdout?: string } | { stderr?: string } | { exit: number }> };
13
+ export type ExecSpec = { args: string[]; env?: Record<string, string>; cwd?: string };
14
+ export type MachinePool = {
15
+ readonly kind: 'docker' | 'none' | 'fake';
16
+ /** runs the image; a failure (no container runtime, an image that will not start) rejects with the runtime's message */
17
+ run(spec: MachineSpec): Promise<MachineRun>;
18
+ /** starts a process in a running machine; a machine the pool does not run rejects */
19
+ exec(name: string, spec: ExecSpec): Promise<MachineProcess>;
20
+ stop(name: string): Promise<void>;
21
+ remove(name: string): Promise<void>;
22
+ /** the fake pool's record of what it was asked */
23
+ readonly asked?: Array<{ act: 'run' | 'exec' | 'stop' | 'remove'; name: string; spec?: MachineSpec; exec?: ExecSpec }>;
24
+ };
25
+ export const MACHINE_POOL = '_machine_pool';
26
+ export const POOL_KINDS = ['docker', 'none', 'fake'] as const;
27
+
28
+ /** A container's name for a machine: the World's prefix, the World's own mark (a machine id repeats across Worlds and
29
+ * branches, which mint alike), then the vendor's machine name made safe for docker. */
30
+ const containerName = (world: string, name: string): string => `volter-world-machine-${world}-${name.replace(/[^A-Za-z0-9_.-]/g, '-')}`;
31
+
32
+ // an image's pull is the slow step: a run may take two minutes before the pool gives up; anything else, thirty seconds
33
+ const RUN_TIMEOUT_MS = 120_000;
34
+ const STEP_TIMEOUT_MS = 30_000;
35
+
36
+ function dockerPool(world: string): MachinePool {
37
+ // asynchronous, so a pull never holds the World's other requests (or a socket's heartbeats) behind it
38
+ const docker = (args: string[], timeout = STEP_TIMEOUT_MS): Promise<string> => new Promise((resolve, reject) => {
39
+ const { execFile } = nodeBuiltin<typeof import('node:child_process')>('node:child_process');
40
+ execFile('docker', args, { encoding: 'utf8', timeout }, (error, stdout, stderr) => (error ? reject(new Error(String(stderr || error.message).trim())) : resolve(String(stdout).trim())));
41
+ });
42
+ return {
43
+ kind: 'docker',
44
+ async run(spec) {
45
+ const name = containerName(world, spec.name);
46
+ await docker(['rm', '-f', name]).catch(() => undefined);
47
+ const env = Object.entries(spec.env ?? {}).flatMap(([k, v]) => ['-e', `${k}=${v}`]);
48
+ const ports = (spec.ports ?? []).flatMap((p) => ['-p', `127.0.0.1::${p}`]);
49
+ await docker(['run', '-d', '--name', name, ...env, ...ports, spec.image, ...(spec.command ?? [])], RUN_TIMEOUT_MS);
50
+ const published: MachineRun['ports'] = [];
51
+ for (const internal of spec.ports ?? []) {
52
+ const line = (await docker(['port', name, String(internal)])).split('\n')[0] ?? '';
53
+ published.push({ internal, host: Number(line.split(':').at(-1)) });
54
+ }
55
+ return { ports: published };
56
+ },
57
+ async exec(name, spec) {
58
+ const { spawn } = nodeBuiltin<typeof import('node:child_process')>('node:child_process');
59
+ const env = Object.entries(spec.env ?? {}).flatMap(([k, v]) => ['-e', `${k}=${v}`]);
60
+ // the shell names its own pid first, then becomes the process: its pid is the process's
61
+ const child = spawn('docker', ['exec', '-i', ...env, ...(spec.cwd ? ['-w', spec.cwd] : []), containerName(world, name), 'sh', '-c', 'echo "@pid $$"; exec "$@"', 'sh', ...spec.args], { stdio: ['ignore', 'pipe', 'pipe'] });
62
+ const queue: Array<{ stdout?: string } | { stderr?: string } | { exit: number }> = [];
63
+ let wake: (() => void) | undefined;
64
+ let done = false;
65
+ let pid: number | undefined;
66
+ let head = '';
67
+ let stderr = '';
68
+ let settle: { resolve: (n: number) => void; reject: (e: Error) => void } | undefined;
69
+ const pidKnown = new Promise<number>((resolve, reject) => { settle = { resolve, reject }; child.on('error', reject); });
70
+ const push = (e: (typeof queue)[number]): void => { queue.push(e); wake?.(); };
71
+ child.stdout!.setEncoding('utf8').on('data', (chunk: string) => {
72
+ if (pid === undefined) {
73
+ // the pid line may arrive split across chunks: nothing is output until its newline is read
74
+ head += chunk;
75
+ const nl = head.indexOf('\n');
76
+ if (nl < 0) return;
77
+ const m = /^@pid (\d+)$/.exec(head.slice(0, nl));
78
+ if (!m) { push({ stdout: head }); head = ''; return; }
79
+ pid = Number(m[1]);
80
+ settle?.resolve(pid);
81
+ chunk = head.slice(nl + 1);
82
+ head = '';
83
+ }
84
+ if (chunk) push({ stdout: chunk });
85
+ });
86
+ child.stderr!.setEncoding('utf8').on('data', (chunk: string) => { if (pid === undefined) stderr += chunk; push({ stderr: chunk }); });
87
+ child.on('close', (code, signal) => {
88
+ // a machine the pool does not run (docker exec fails before the shell starts) rejects, with docker's words
89
+ if (pid === undefined) settle?.reject(new Error(stderr.trim() || `the process did not start (exit ${code ?? signal})`));
90
+ // a process ended by a signal exits as a shell reports it: 128 and the signal's number
91
+ const signals: Record<string, number> = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGKILL: 9, SIGTERM: 15 };
92
+ push({ exit: code ?? (signal ? 128 + (signals[signal] ?? 0) : 0) });
93
+ done = true; wake?.();
94
+ });
95
+ const events: MachineProcess['events'] = {
96
+ async *[Symbol.asyncIterator]() {
97
+ for (;;) {
98
+ while (queue.length) { const e = queue.shift()!; yield e; if ('exit' in e) return; }
99
+ if (done) return;
100
+ await new Promise<void>((r) => { wake = r; });
101
+ }
102
+ },
103
+ };
104
+ let timer: ReturnType<typeof setTimeout> | undefined;
105
+ const timeout = new Promise<number>((_, reject) => { timer = setTimeout(() => reject(new Error('the process did not start')), STEP_TIMEOUT_MS); });
106
+ try { return { pid: await Promise.race([pidKnown, timeout]), events }; } finally { clearTimeout(timer); }
107
+ },
108
+ async stop(name) { await docker(['stop', containerName(world, name)]).catch(() => undefined); },
109
+ async remove(name) { await docker(['rm', '-f', containerName(world, name)]).catch(() => undefined); },
110
+ };
111
+ }
112
+
113
+ /** The pool of a kind, for a World (`world`: the mark its containers carry). */
114
+ export function machinePool(kind: MachinePool['kind'], world = 'world'): MachinePool {
115
+ if (kind === 'docker') return dockerPool(world);
116
+ if (kind === 'fake') {
117
+ const asked: NonNullable<MachinePool['asked']> = [];
118
+ return {
119
+ kind, asked,
120
+ async run(spec) { asked.push({ act: 'run', name: spec.name, spec }); return { ports: (spec.ports ?? []).map((internal) => ({ internal, host: 40_000 + internal })) }; },
121
+ async exec(name, spec) {
122
+ asked.push({ act: 'exec', name, exec: spec });
123
+ // a fake process: its pid the count of processes asked, and it ends at once
124
+ const pid = asked.filter((a) => a.act === 'exec').length + 100;
125
+ return { pid, events: (async function* () { yield { exit: 0 }; })() };
126
+ },
127
+ async stop(name) { asked.push({ act: 'stop', name }); },
128
+ async remove(name) { asked.push({ act: 'remove', name }); },
129
+ };
130
+ }
131
+ return {
132
+ kind: 'none', async run() { return { ports: [] }; },
133
+ async exec() { throw new Error('no machine runs in this World: its machine pool is none'); },
134
+ async stop() { /* nothing runs */ }, async remove() { /* nothing runs */ },
135
+ };
136
+ }
137
+
138
+ /** A vendor's pool in a World, one per World and kind for the life of the process (a fake pool keeps what it was asked). */
139
+ const pools = new Map<string, MachinePool>();
140
+ export function poolFor(service: string, root: string | undefined, kind: MachinePool['kind']): MachinePool {
141
+ const key = `${service}\0${root ?? ''}\0${kind}`;
142
+ let pool = pools.get(key);
143
+ if (!pool) {
144
+ // the World's mark: a short digest of the vendor and the World's tree, so two Worlds' machines never share a name
145
+ let h = 0;
146
+ for (const ch of `${service}:${root ?? ''}`) h = (Math.imul(h, 31) + ch.charCodeAt(0)) >>> 0;
147
+ pool = machinePool(kind, h.toString(16).padStart(8, '0'));
148
+ pools.set(key, pool);
149
+ }
150
+ return pool;
151
+ }
@@ -0,0 +1,299 @@
1
+ // THE WORLD'S MANAGED DATABASE, as a pack reaches it (architecture.md, "The engine slot" and managed infrastructure): a
2
+ // vendor whose data plane is a server in front of Postgres (Supabase's PostgREST, Storage and Auth; a Neon or a
3
+ // PlanetScale) serves it over the World's own Postgres, whose URL the runtime hands the pack (the descriptor's
4
+ // `managedDatabase`: `serve --database <url>`, or the colocated host's `database` option). A protocol-3 handler reaches
5
+ // it as `ctx.engine`; a pack's own server in front of it (a PostgREST) through `managedDatabase(url)`. Its terms:
6
+ //
7
+ // - Nothing here reads the environment, and the tree references none of the rows it writes.
8
+ // - Each batch is ONE transaction sent as ONE extended-protocol pipeline ending in ONE Sync: `BEGIN`, the role
9
+ // (`SET LOCAL ROLE`), the transaction-local settings (`set_config(…, true)`), the statements and `COMMIT`, every value a
10
+ // bind parameter. The PGlite host runs a connection's messages up to its Sync under one lock and lets another
11
+ // connection's read into a session idle inside a transaction; a batch that never sits idle inside its transaction lets
12
+ // nobody in, so its role and settings reach no other connection.
13
+ // - The role, which Postgres takes only as SQL text, is quoted as an identifier; which roles a caller may take is the
14
+ // pack's rule (Supabase's three API roles), checked before a batch is made.
15
+ // - A batch that fails leaves its transaction aborted (the backend skips to the Sync); it is ended with `ROLLBACK` before
16
+ // the connection is released, and a connection that cannot be rolled back is destroyed, never reused.
17
+ //
18
+ // The Postgres driver is node-postgres (`pg`), loaded the first time a database is bound and installed by the package that
19
+ // declares a managed database (its package.json): the kernel's dependencies are zod and ws alone (architecture B1), and a
20
+ // browser bundle imports the kernel without a database. A host may register another (`useDatabaseDriver`).
21
+
22
+ /** One statement of a batch; `values` travel as bind parameters (text, or null), never in the SQL text. */
23
+ export type Statement = { text: string; values?: ReadonlyArray<string | null> };
24
+
25
+ /** What one statement answered: its columns and rows in Postgres's text format, and its command tag. */
26
+ export type StatementResult = { fields: string[]; rows: Array<Array<string | null>>; command: string };
27
+
28
+ /** A Postgres error as the backend reported it (SQLSTATE and its fields), what a vendor's server passes through. */
29
+ export class EngineDatabaseError extends Error {
30
+ readonly code: string;
31
+ readonly detail: string | null;
32
+ readonly hint: string | null;
33
+ constructor(message: string, fields: { code?: string; detail?: string; hint?: string }) {
34
+ super(message);
35
+ this.name = 'EngineDatabaseError';
36
+ this.code = fields.code ?? 'XX000';
37
+ this.detail = fields.detail ?? null;
38
+ this.hint = fields.hint ?? null;
39
+ }
40
+ }
41
+
42
+ /** The World's database cannot be reached: none is bound, or it does not answer. */
43
+ export class EngineUnavailableError extends Error {
44
+ constructor(message: string) { super(message); this.name = 'EngineUnavailableError'; }
45
+ }
46
+
47
+ /** One transaction: as `role` (none: the twin's own login role, the vendor's servers' own work), with transaction-local
48
+ * `settings` (`request.jwt.claims`, `search_path`), READ ONLY when `readOnly` (a write is refused by Postgres itself,
49
+ * 25006), ended with ROLLBACK when `rollback` (a permission probe asked of Postgres and undone). */
50
+ export type Batch = { role?: string; settings?: Record<string, string>; readOnly?: boolean; rollback?: boolean; statements: Statement[] };
51
+
52
+ export interface ManagedDatabase {
53
+ /** Whether the World binds a database at all (a World with none refuses every batch). */
54
+ readonly bound: boolean;
55
+ /** One transaction as one pipeline and one Sync; the answer is the batch's own statements', in order. */
56
+ batch(batch: Batch): Promise<StatementResult[]>;
57
+ /** A whole SQL script as one simple Query (a vendor's migrations, several statements to a file). */
58
+ script(text: string): Promise<void>;
59
+ }
60
+
61
+ /** The transaction-local setting every batch a handler makes carries: the World clock at the call (ctx.occurredAt). */
62
+ export const WORLD_CLOCK_SETTING = 'volter.world_clock';
63
+
64
+ /** The World clock inside the database, for the schemas a vendor lays (its migrations stamp rows with Postgres's
65
+ * `now()`, which reads the wall clock): `volter.now()`, the World clock the batch set or the wall clock where none is
66
+ * set, and every column default in `schemas` that reads `now()` or `CURRENT_TIMESTAMP` (alone or inside an expression,
67
+ * GoTrue's `timezone('utc', now())`) rewritten to read it. Run after the schemas are laid; running it again changes
68
+ * nothing. A trigger or function that calls `now()` itself is the pack's to redefine. */
69
+ export function worldClockSql(schemas: string[]): string {
70
+ const list = schemas.map((s) => `'${s.replace(/'/g, "''")}'`).join(', ');
71
+ return `CREATE SCHEMA IF NOT EXISTS volter;
72
+ CREATE OR REPLACE FUNCTION volter.now() RETURNS timestamptz LANGUAGE sql STABLE AS $fn$
73
+ SELECT coalesce(nullif(current_setting('${WORLD_CLOCK_SETTING}', true), '')::timestamptz, pg_catalog.now())
74
+ $fn$;
75
+ GRANT USAGE ON SCHEMA volter TO PUBLIC;
76
+ GRANT EXECUTE ON FUNCTION volter.now() TO PUBLIC;
77
+ DO $do$
78
+ DECLARE d record; rewritten text;
79
+ BEGIN
80
+ FOR d IN
81
+ SELECT n.nspname AS schema, c.relname AS tbl, a.attname AS col, pg_get_expr(ad.adbin, ad.adrelid) AS expr
82
+ FROM pg_attrdef ad
83
+ JOIN pg_class c ON c.oid = ad.adrelid
84
+ JOIN pg_namespace n ON n.oid = c.relnamespace
85
+ JOIN pg_attribute a ON a.attrelid = ad.adrelid AND a.attnum = ad.adnum
86
+ WHERE n.nspname IN (${list}) AND c.relkind IN ('r', 'p')
87
+ AND pg_get_expr(ad.adbin, ad.adrelid) ~* '(^|[^.[:alnum:]_])(now\\(\\)|current_timestamp)'
88
+ LOOP
89
+ rewritten := regexp_replace(d.expr, '(^|[^.[:alnum:]_])(now\\(\\)|current_timestamp)', '\\1volter.now()', 'gi');
90
+ EXECUTE format('ALTER TABLE %I.%I ALTER COLUMN %I SET DEFAULT %s', d.schema, d.tbl, d.col, rewritten);
91
+ END LOOP;
92
+ END
93
+ $do$;`;
94
+ }
95
+
96
+ /** Quote an identifier the way Postgres's quote_ident does for a name that needs it. */
97
+ export function quoteIdent(name: string): string {
98
+ return `"${name.replace(/"/g, '""')}"`;
99
+ }
100
+
101
+ type BackendMessage = { fields?: unknown; text?: string };
102
+ type PgConnection = {
103
+ parse(query: { text: string; types: number[] }): void;
104
+ bind(config: { values: ReadonlyArray<string | null> }): void;
105
+ describe(message: { type: 'P' }): void;
106
+ execute(config: { rows: number }): void;
107
+ sync(): void;
108
+ sendCopyFail(message: string): void;
109
+ };
110
+
111
+ /** pg's Submittable (the extension point pg-cursor and pg-query-stream use): the client hands it the connection once
112
+ * it is its turn, it writes every statement's Parse/Bind/Describe/Execute and ONE Sync, and the client hands it each
113
+ * backend message until ReadyForQuery. */
114
+ class Pipeline {
115
+ private readonly results: StatementResult[] = [];
116
+ private current: { fields: string[]; rows: Array<Array<string | null>> } | null = null;
117
+ private error: Error | null = null;
118
+ private settled = false;
119
+ constructor(private readonly statements: Statement[], private readonly done: (error: Error | null, results: StatementResult[]) => void) {}
120
+ submit(connection: PgConnection): void {
121
+ for (const statement of this.statements) {
122
+ connection.parse({ text: statement.text, types: [] });
123
+ connection.bind({ values: statement.values ?? [] });
124
+ connection.describe({ type: 'P' });
125
+ connection.execute({ rows: 0 });
126
+ }
127
+ connection.sync();
128
+ }
129
+ handleRowDescription(message: { fields: Array<{ name: string }> }): void {
130
+ this.current = { fields: message.fields.map((field) => field.name), rows: [] };
131
+ }
132
+ handleDataRow(message: { fields: Array<string | null> }): void {
133
+ (this.current ??= { fields: [], rows: [] }).rows.push(message.fields);
134
+ }
135
+ handleCommandComplete(message: BackendMessage): void {
136
+ this.results.push({ ...(this.current ?? { fields: [], rows: [] }), command: String(message.text ?? '') });
137
+ this.current = null;
138
+ }
139
+ handleEmptyQuery(): void {
140
+ this.results.push({ fields: [], rows: [], command: '' });
141
+ }
142
+ handlePortalSuspended(): void { /* every Execute asks for all rows */ }
143
+ handleCopyInResponse(connection: PgConnection): void { connection.sendCopyFail('COPY FROM STDIN is not served by a managed database batch'); }
144
+ handleCopyData(): void { /* no COPY is ever sent */ }
145
+ handleError(error: Error): void {
146
+ // An ErrorResponse (the backend's, with a SQLSTATE) or a failed connection. pg ends the submittable's turn here:
147
+ // it drops it as the active query, so the ReadyForQuery the backend sends after skipping to the Sync goes to no
148
+ // one, and the next query this client is given (the ROLLBACK) is sent once that ReadyForQuery arrives.
149
+ this.error ??= error;
150
+ this.finish();
151
+ }
152
+ handleReadyForQuery(): void { this.finish(); }
153
+ private finish(): void {
154
+ if (this.settled) return;
155
+ this.settled = true;
156
+ this.done(this.error, this.results);
157
+ }
158
+ }
159
+
160
+ function asDatabaseError(error: Error): Error {
161
+ const fields = error as { code?: string; detail?: string; hint?: string; severity?: string };
162
+ if (fields.severity === undefined) return new EngineUnavailableError(`the World's Postgres failed: ${error.message}`);
163
+ return new EngineDatabaseError(error.message, fields);
164
+ }
165
+
166
+ /** The part of a Postgres driver the kernel uses: node-postgres's (`pg`) Pool and its clients. */
167
+ type PgClient = { query(query: unknown): unknown; release(error?: Error): void };
168
+ type PgPool = { connect(): Promise<PgClient>; end(): Promise<void>; on(event: 'error', listener: (error: Error) => void): unknown };
169
+ export type DatabaseDriver = { Pool: new (config: { connectionString: string; max: number; idleTimeoutMillis: number; connectionTimeoutMillis: number }) => PgPool };
170
+
171
+ let driver: DatabaseDriver | undefined;
172
+
173
+ /** A Postgres driver a host registers in place of node-postgres. */
174
+ export function useDatabaseDriver(given: DatabaseDriver): void {
175
+ driver = given;
176
+ }
177
+
178
+ /** The driver: the registered one, else node-postgres, loaded on first use (a name the bundler does not resolve). */
179
+ async function loadDriver(): Promise<DatabaseDriver> {
180
+ if (driver !== undefined) return driver;
181
+ const name = 'pg';
182
+ try {
183
+ const mod = (await import(name)) as { default?: DatabaseDriver } & DatabaseDriver;
184
+ driver = mod.default ?? mod;
185
+ } catch (error) {
186
+ throw new EngineUnavailableError(`no Postgres driver: node-postgres (pg) is not installed beside the pack that declares a managed database (${(error as Error).message})`);
187
+ }
188
+ return driver;
189
+ }
190
+
191
+ const pools = new Map<string, PgPool>(); // cache: one pool per bound database URL — the connections this process holds to the World's Postgres, never state
192
+
193
+ async function poolFor(url: string): Promise<PgPool> {
194
+ let pool = pools.get(url);
195
+ if (pool === undefined) {
196
+ const { Pool } = await loadDriver();
197
+ // another request may have made it while the driver loaded
198
+ const made = pools.get(url);
199
+ if (made !== undefined) return made;
200
+ // a few connections: the PGlite host serializes them anyway, and a container Postgres needs no more for a twin
201
+ pool = new Pool({ connectionString: url, max: 4, idleTimeoutMillis: 10_000, connectionTimeoutMillis: 10_000 });
202
+ // an idle connection that the database ends (a World's Postgres restarting) is dropped from the pool, not thrown
203
+ pool.on('error', () => {});
204
+ pools.set(url, pool);
205
+ }
206
+ return pool;
207
+ }
208
+
209
+ async function checkout(url: string): Promise<PgClient> {
210
+ const pool = await poolFor(url);
211
+ try {
212
+ return await pool.connect();
213
+ } catch (error) {
214
+ throw new EngineUnavailableError(`the World's Postgres is not answering: ${(error as Error).message}`);
215
+ }
216
+ }
217
+
218
+ function pipeline(client: PgClient, statements: Statement[]): Promise<{ error: Error | null; results: StatementResult[] }> {
219
+ return new Promise((resolve) => {
220
+ (client as unknown as { query: (submittable: Pipeline) => void }).query(new Pipeline(statements, (error, results) => resolve({ error, results })));
221
+ });
222
+ }
223
+
224
+ /** Run one transaction as one pipeline with one Sync; on failure, ROLLBACK before the connection goes back. */
225
+ async function transaction(url: string, statements: Statement[], lead: number): Promise<StatementResult[]> {
226
+ const client = await checkout(url);
227
+ let broken: Error | undefined;
228
+ try {
229
+ const { error, results } = await pipeline(client, statements);
230
+ if (error !== null) {
231
+ if ((error as { severity?: unknown }).severity !== undefined) {
232
+ try { await (client.query('ROLLBACK') as Promise<unknown>); } catch (rollback) { broken = rollback as Error; }
233
+ } else broken = error;
234
+ throw asDatabaseError(error);
235
+ }
236
+ // the answer is the batch's own statements: not BEGIN, the role, the settings, or its end
237
+ return results.slice(lead, statements.length - 1);
238
+ } finally {
239
+ client.release(broken);
240
+ }
241
+ }
242
+
243
+ function settingsStatement(settings: Record<string, string>): Statement | null {
244
+ const names = Object.keys(settings);
245
+ if (names.length === 0) return null;
246
+ const values: string[] = [];
247
+ const calls = names.map((name) => {
248
+ values.push(name, settings[name]!);
249
+ return `set_config($${values.length - 1}, $${values.length}, true)`;
250
+ });
251
+ return { text: `SELECT ${calls.join(', ')}`, values };
252
+ }
253
+
254
+ /** The World's Postgres at `url` (the runtime's binding, never the environment); with `readOnly`, every transaction is
255
+ * READ ONLY whatever the batch asks (a read-only twin or request: Postgres itself refuses a write, 25006); with `clock`,
256
+ * every batch carries the World clock as `volter.world_clock`. */
257
+ export function managedDatabase(url: string, options: { readOnly?: boolean; clock?: string } = {}): ManagedDatabase {
258
+ return {
259
+ bound: true,
260
+ async batch(batch) {
261
+ // the World clock at the call, which volter.now() and the defaults worldClockSql rewrote read
262
+ const set = settingsStatement({ ...(options.clock !== undefined ? { [WORLD_CLOCK_SETTING]: options.clock } : {}), ...(batch.settings ?? {}) });
263
+ const head: Statement[] = [
264
+ { text: batch.readOnly || options.readOnly ? 'BEGIN READ ONLY' : 'BEGIN' },
265
+ ...(batch.role === undefined ? [] : [{ text: `SET LOCAL ROLE ${quoteIdent(batch.role)}` }]),
266
+ ...(set === null ? [] : [set]),
267
+ ];
268
+ return transaction(url, [...head, ...batch.statements, { text: batch.rollback ? 'ROLLBACK' : 'COMMIT' }], head.length);
269
+ },
270
+ async script(text) {
271
+ const client = await checkout(url);
272
+ let broken: Error | undefined;
273
+ try {
274
+ await (client.query(text) as Promise<unknown>);
275
+ } catch (error) {
276
+ if ((error as { severity?: unknown }).severity === undefined) broken = error as Error;
277
+ else { try { await (client.query('ROLLBACK') as Promise<unknown>); } catch (rollback) { broken = rollback as Error; } }
278
+ throw asDatabaseError(error as Error);
279
+ } finally {
280
+ client.release(broken);
281
+ }
282
+ },
283
+ };
284
+ }
285
+
286
+ /** A handler's database when the World binds none: every use is the refusal naming what to add. */
287
+ export function unboundDatabase(service: string): ManagedDatabase {
288
+ const refuse = (): never => {
289
+ throw new EngineUnavailableError(`This World binds no managed Postgres to the ${service} twin, so what it serves over one is refused, not served: add a postgres service to the World's managed infrastructure (architecture: managed infrastructure)`);
290
+ };
291
+ return { bound: false, batch: async () => refuse(), script: async () => refuse() };
292
+ }
293
+
294
+ /** Close every connection this process holds (a test's teardown; a served twin's connections end with it). */
295
+ export async function closeManagedDatabases(): Promise<void> {
296
+ const held = [...pools.values()];
297
+ pools.clear();
298
+ await Promise.all(held.map((pool) => pool.end().catch(() => {})));
299
+ }
@@ -0,0 +1,51 @@
1
+ // A multipart/form-data body's parts, read from its bytes (RFC 7578): the one reading every pack's uploads go through.
2
+ // A runtime's own form parser is not used: Bun's drops a part whose field name is empty, which a browser's or
3
+ // supabase-js's Blob upload sends. Dependency-free and side-effect free (a browser bundle imports it).
4
+
5
+ /** One part: its field name (possibly empty), its filename when it is a file, its media type as sent, its bytes. */
6
+ export type MultipartPart = { name: string; filename: string | null; type: string | null; body: Uint8Array };
7
+
8
+ /** The boundary a `multipart/form-data` content type names, or null (not multipart, or no boundary). */
9
+ export function multipartBoundary(contentType: string | null | undefined): string | null {
10
+ if (!contentType || !/^multipart\/form-data/i.test(contentType)) return null;
11
+ const hit = /boundary=(?:"([^"]+)"|([^;\s]+))/i.exec(contentType);
12
+ return hit ? (hit[1] ?? hit[2]!) : null;
13
+ }
14
+
15
+ function indexOf(haystack: Uint8Array, needle: Uint8Array, from: number): number {
16
+ outer: for (let i = from; i <= haystack.length - needle.length; i += 1) {
17
+ for (let j = 0; j < needle.length; j += 1) if (haystack[i + j] !== needle[j]) continue outer;
18
+ return i;
19
+ }
20
+ return -1;
21
+ }
22
+
23
+ /** The parts of a body delimited by `boundary`, in order; a malformed tail ends the reading. */
24
+ export function multipartParts(bytes: Uint8Array, boundary: string): MultipartPart[] {
25
+ const encoder = new TextEncoder();
26
+ const decoder = new TextDecoder();
27
+ const delimiter = encoder.encode(`--${boundary}`);
28
+ const blank = encoder.encode('\r\n\r\n');
29
+ const parts: MultipartPart[] = [];
30
+ let at = indexOf(bytes, delimiter, 0);
31
+ while (at >= 0) {
32
+ const start = at + delimiter.length;
33
+ // `--` after the delimiter closes the body
34
+ if (bytes[start] === 0x2d && bytes[start + 1] === 0x2d) break;
35
+ const headerEnd = indexOf(bytes, blank, start);
36
+ if (headerEnd < 0) break;
37
+ const next = indexOf(bytes, delimiter, headerEnd + 4);
38
+ if (next < 0) break;
39
+ const head = decoder.decode(bytes.subarray(start, headerEnd));
40
+ const disposition = /content-disposition:[^\r\n]*/i.exec(head)?.[0] ?? '';
41
+ parts.push({
42
+ name: /\bname="([^"]*)"/i.exec(disposition)?.[1] ?? '',
43
+ filename: /\bfilename="([^"]*)"/i.exec(disposition)?.[1] ?? null,
44
+ type: /content-type:\s*([^\r\n]+)/i.exec(head)?.[1]?.trim() ?? null,
45
+ // the CRLF before the next delimiter is the delimiter's, not the part's
46
+ body: bytes.slice(headerEnd + 4, next - 2),
47
+ });
48
+ at = next;
49
+ }
50
+ return parts;
51
+ }
package/src/observe.ts CHANGED
@@ -7,24 +7,27 @@
7
7
  // unchanged resource appends nothing; a listing the adapter declares complete tombstones what it
8
8
  // no longer holds. A refresh runs inside a scope that collects the adapter's observations and folds
9
9
  // them as one batch; an ingest observes directly, one resource at a time.
10
- import { withAncestryLock } from './ancestry.ts';
10
+ import { withHistoryLock } from './ancestry.ts';
11
11
  import { AsyncLocalStorage } from 'node:async_hooks';
12
12
  import { appendParentEntry, readParentTreeMap, type Entry, type Tree } from './log.ts';
13
13
  import { hashFieldValue, type SubjectFields } from './hash.ts';
14
14
  import { worldNow } from './world-clock.ts';
15
+ import { worldPaths } from './storage.ts';
15
16
 
16
- export type ObservedResource = { type: string; id: string; fields: SubjectFields; deleted?: boolean };
17
+ /** `adopts`: the local id of a subject a deploy settled with no vendor id, which this observation is (refresh's
18
+ * adoption, architecture "Refresh"): the observation lands as that subject's alias, and the local one stands. */
19
+ export type ObservedResource = { type: string; id: string; fields: SubjectFields; deleted?: boolean; adopts?: string };
17
20
  export type ObserveReport = { observed: number; appended: number; unchanged: number; removed: number };
18
21
 
19
22
  export type Observation = { service: string; root?: string } & (
20
23
  | { resource: ObservedResource }
21
- | { resources: ObservedResource[]; complete?: string[] }
24
+ | { resources: ObservedResource[]; complete?: Completed[] }
22
25
  );
23
26
  // ONE sink per process, whichever copy of the kernel a pack resolved: a pack installed beside a
24
27
  // world carries its own `@volter/world-core`, and its observations must reach the scope the runtime
25
28
  // opened. A module-level instance would be one per copy; a process-global symbol is one per process.
26
29
  const SINK = Symbol.for('volter.observe.sink');
27
- // created on first use, never at import: a mirror's CLIENT bundle (target browser) carries this module
30
+ // created on first use, never at import: a browser bundle carrying the kernel carries this module
28
31
  // through the pack's shared helpers, and a browser has no AsyncLocalStorage — constructing one at load
29
32
  // would kill the whole client ("not a constructor") for a scope the browser never opens
30
33
  const sink = (): AsyncLocalStorage<Observation[]> => ((globalThis as Record<symbol, unknown>)[SINK] ??= new AsyncLocalStorage<Observation[]>()) as AsyncLocalStorage<Observation[]>;
@@ -37,10 +40,14 @@ export async function collectObservations<T>(fn: () => Promise<T>): Promise<{ va
37
40
  return { value, observations };
38
41
  }
39
42
 
43
+ /** A type a batch listed in full: all of it, or (a type under a parent) the subjects whose `field` names one of the
44
+ * parents listed `within`. */
45
+ export type Completed = string | { type: string; field: string; within: string[] };
46
+
40
47
  /** Fold a collected batch: one `observeResources` per service, `complete` applied to each. */
41
- export function foldObservations(observations: Observation[], opts: { root?: string; at?: string; complete?: string[]; batch?: string } = {}): ObserveReport {
48
+ export function foldObservations(observations: Observation[], opts: { root?: string; at?: string; complete?: Completed[]; batch?: string } = {}): ObserveReport {
42
49
  const total: ObserveReport = { observed: 0, appended: 0, unchanged: 0, removed: 0 };
43
- const groups = new Map<string, { service: string; root?: string; resources: ObservedResource[]; complete: Set<string> }>();
50
+ const groups = new Map<string, { service: string; root?: string; resources: ObservedResource[]; complete: Set<Completed> }>();
44
51
  for (const o of observations) {
45
52
  const root = opts.root ?? o.root;
46
53
  const key = JSON.stringify([o.service, root]);
@@ -72,9 +79,9 @@ function stable(value: unknown): string {
72
79
  }
73
80
 
74
81
  /** The fold: diff each resource against upstream state, append what changed to the root's log. `complete`
75
- * names the types this batch listed in full — a subject of such a type the batch does not hold is
76
- * tombstoned. Entry ids are content-addressed, so the same observation folds once. */
77
- export function observeResources(service: string, resources: ObservedResource[], opts: { root?: string; at?: string; complete?: string[]; batch?: string } = {}): ObserveReport {
82
+ * names the types this batch listed in full (or in full under the parents it names) — a subject of such a type the
83
+ * batch does not hold is tombstoned. Entry ids are content-addressed, so the same observation folds once. */
84
+ export function observeResources(service: string, resources: ObservedResource[], opts: { root?: string; at?: string; complete?: Completed[]; batch?: string } = {}): ObserveReport {
78
85
  const collecting = typeof AsyncLocalStorage === 'function' ? sink().getStore() : undefined;
79
86
  if (collecting) {
80
87
  collecting.push({ service, resources, ...(opts.root !== undefined ? { root: opts.root } : {}), ...(opts.complete ? { complete: opts.complete } : {}) });
@@ -83,18 +90,18 @@ export function observeResources(service: string, resources: ObservedResource[],
83
90
  return foldResources(service, resources, opts);
84
91
  }
85
92
 
86
- function foldResources(service: string, resources: ObservedResource[], opts: { root?: string; at?: string; complete?: string[]; batch?: string }): ObserveReport {
87
- return withAncestryLock(() => {
93
+ function foldResources(service: string, resources: ObservedResource[], opts: { root?: string; at?: string; complete?: Completed[]; batch?: string }): ObserveReport {
94
+ return withHistoryLock(worldPaths(service, opts.root).dir, () => {
88
95
  const at = opts.at ?? worldNow();
89
96
  // one look is one batch: every entry it appends carries the same id, and no position may split it
90
97
  const batch = opts.batch ?? `obs:${service}:${at}`;
91
98
  const tree: Tree = readParentTreeMap(service, opts.root);
92
99
  const report: ObserveReport = { observed: resources.length, appended: 0, unchanged: 0, removed: 0 };
93
100
  const seen = new Set<string>();
94
- const append = (subject: { type: string; id: string }, fields: SubjectFields): boolean => {
101
+ const append = (subject: { type: string; id: string }, fields: SubjectFields, aliasOf?: string): boolean => {
95
102
  const entry: Entry = {
96
- id: `observed:${service}:${subject.type}:${subject.id}:${hashFieldValue({ subject, fields, at })}`,
97
- service, op: 'set', operation: 'observed', subject, occurredAt: at, actor: { kind: 'system', id: 'vendor' }, fields, batch,
103
+ id: `observed:${service}:${subject.type}:${subject.id}:${hashFieldValue({ subject, fields, at, ...(aliasOf ? { aliasOf } : {}) })}`,
104
+ service, op: 'set', operation: 'observed', subject, occurredAt: at, actor: { kind: 'system', id: 'vendor' }, fields, batch, ...(aliasOf ? { aliasOf } : {}),
98
105
  };
99
106
  const { appended } = appendParentEntry(entry, opts.root);
100
107
  const key = `${subject.type}:${subject.id}`;
@@ -107,6 +114,12 @@ function foldResources(service: string, resources: ObservedResource[], opts: { r
107
114
  for (const r of resources) {
108
115
  const key = `${r.type}:${r.id}`;
109
116
  seen.add(key);
117
+ // an adoption: the vendor's subject is the local one a deploy made with no id, aliased to it
118
+ if (r.adopts !== undefined && !tree.has(key)) {
119
+ seen.add(`${r.type}:${r.adopts}`);
120
+ if (append({ type: r.type, id: r.id }, r.fields, r.adopts)) report.appended += 1; else report.unchanged += 1;
121
+ continue;
122
+ }
110
123
  const existing = tree.get(key);
111
124
  if (r.deleted) {
112
125
  if (existing && existing.fields.deleted !== true) { if (append({ type: r.type, id: r.id }, { deleted: true })) report.removed += 1; }
@@ -119,9 +132,12 @@ function foldResources(service: string, resources: ObservedResource[], opts: { r
119
132
  if (existing?.fields.deleted === true && Object.keys(changed).length === 0) Object.assign(changed, r.fields); // resurrected as it stands
120
133
  if (append({ type: r.type, id: r.id }, changed)) report.appended += 1; else report.unchanged += 1;
121
134
  }
122
- for (const type of opts.complete ?? []) {
135
+ for (const c of opts.complete ?? []) {
136
+ const type = typeof c === 'string' ? c : c.type;
137
+ const within = typeof c === 'string' ? undefined : new Set(c.within);
123
138
  for (const s of tree.values()) {
124
139
  if (s.type !== type || seen.has(`${s.type}:${s.id}`) || s.fields.deleted === true) continue;
140
+ if (within && typeof c !== 'string' && !within.has(String(s.fields[c.field]))) continue;
125
141
  if (append({ type: s.type, id: s.id }, { deleted: true })) report.removed += 1;
126
142
  }
127
143
  }