primitive-admin 1.1.0-alpha.81 → 1.1.0-alpha.83

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 (109) hide show
  1. package/assets/skill/skills/primitive-platform/SKILL.md +12 -0
  2. package/dist/src/commands/documents.js +455 -22
  3. package/dist/src/commands/documents.js.map +1 -1
  4. package/dist/src/commands/functions.js +889 -76
  5. package/dist/src/commands/functions.js.map +1 -1
  6. package/dist/src/commands/locks.js +15 -1
  7. package/dist/src/commands/locks.js.map +1 -1
  8. package/dist/src/commands/prompts.js +19 -2
  9. package/dist/src/commands/prompts.js.map +1 -1
  10. package/dist/src/commands/sync.d.ts +50 -0
  11. package/dist/src/commands/sync.js +587 -316
  12. package/dist/src/commands/sync.js.map +1 -1
  13. package/dist/src/commands/workflows.d.ts +1 -29
  14. package/dist/src/commands/workflows.js +8 -109
  15. package/dist/src/commands/workflows.js.map +1 -1
  16. package/dist/src/lib/api-client.d.ts +191 -2
  17. package/dist/src/lib/api-client.js +283 -7
  18. package/dist/src/lib/api-client.js.map +1 -1
  19. package/dist/src/lib/config-object-descriptor.js +12 -4
  20. package/dist/src/lib/config-object-descriptor.js.map +1 -1
  21. package/dist/src/lib/document-ingest-artifact.d.ts +120 -0
  22. package/dist/src/lib/document-ingest-artifact.js +505 -0
  23. package/dist/src/lib/document-ingest-artifact.js.map +1 -0
  24. package/dist/src/lib/document-ingest-input.d.ts +51 -0
  25. package/dist/src/lib/document-ingest-input.js +132 -0
  26. package/dist/src/lib/document-ingest-input.js.map +1 -0
  27. package/dist/src/lib/document-ingest.d.ts +78 -0
  28. package/dist/src/lib/document-ingest.js +231 -0
  29. package/dist/src/lib/document-ingest.js.map +1 -0
  30. package/dist/src/lib/env-resolver-core.d.ts +2 -2
  31. package/dist/src/lib/env-resolver-core.js +2 -2
  32. package/dist/src/lib/function-bundle.d.ts +6 -0
  33. package/dist/src/lib/function-bundle.js +8 -0
  34. package/dist/src/lib/function-bundle.js.map +1 -1
  35. package/dist/src/lib/function-collect.d.ts +1 -1
  36. package/dist/src/lib/function-collect.js +16 -0
  37. package/dist/src/lib/function-collect.js.map +1 -1
  38. package/dist/src/lib/function-db-types.d.ts +4 -0
  39. package/dist/src/lib/function-db-types.js +46 -2
  40. package/dist/src/lib/function-db-types.js.map +1 -1
  41. package/dist/src/lib/function-document-types.d.ts +83 -0
  42. package/dist/src/lib/function-document-types.js +137 -0
  43. package/dist/src/lib/function-document-types.js.map +1 -1
  44. package/dist/src/lib/function-grants-preflight.js +5 -0
  45. package/dist/src/lib/function-grants-preflight.js.map +1 -1
  46. package/dist/src/lib/function-log-lines.d.ts +76 -0
  47. package/dist/src/lib/function-log-lines.js +160 -0
  48. package/dist/src/lib/function-log-lines.js.map +1 -0
  49. package/dist/src/lib/function-log-row.d.ts +29 -0
  50. package/dist/src/lib/function-log-row.js +73 -0
  51. package/dist/src/lib/function-log-row.js.map +1 -0
  52. package/dist/src/lib/function-log-tail.d.ts +51 -2
  53. package/dist/src/lib/function-log-tail.js +160 -13
  54. package/dist/src/lib/function-log-tail.js.map +1 -1
  55. package/dist/src/lib/function-run.d.ts +289 -0
  56. package/dist/src/lib/function-run.js +389 -0
  57. package/dist/src/lib/function-run.js.map +1 -0
  58. package/dist/src/lib/function-schema-codegen.d.ts +17 -9
  59. package/dist/src/lib/function-schema-codegen.js +30 -29
  60. package/dist/src/lib/function-schema-codegen.js.map +1 -1
  61. package/dist/src/lib/function-sync.d.ts +226 -30
  62. package/dist/src/lib/function-sync.js +731 -77
  63. package/dist/src/lib/function-sync.js.map +1 -1
  64. package/dist/src/lib/function-versions.d.ts +122 -0
  65. package/dist/src/lib/function-versions.js +182 -0
  66. package/dist/src/lib/function-versions.js.map +1 -0
  67. package/dist/src/lib/generated-config-surfaces.d.ts +561 -91
  68. package/dist/src/lib/generated-config-surfaces.js +1858 -287
  69. package/dist/src/lib/generated-config-surfaces.js.map +1 -1
  70. package/dist/src/lib/generated-sdk-types.d.ts +1 -1
  71. package/dist/src/lib/generated-sdk-types.js +1 -1
  72. package/dist/src/lib/generated-sdk-types.js.map +1 -1
  73. package/dist/src/lib/log-inspection.d.ts +2 -0
  74. package/dist/src/lib/log-inspection.js +4 -0
  75. package/dist/src/lib/log-inspection.js.map +1 -1
  76. package/dist/src/lib/output.d.ts +15 -0
  77. package/dist/src/lib/output.js +28 -0
  78. package/dist/src/lib/output.js.map +1 -1
  79. package/dist/src/lib/prompt-schema-codegen.d.ts +94 -0
  80. package/dist/src/lib/prompt-schema-codegen.js +212 -0
  81. package/dist/src/lib/prompt-schema-codegen.js.map +1 -0
  82. package/dist/src/lib/snapshot-audit-source.d.ts +45 -0
  83. package/dist/src/lib/snapshot-audit-source.js +58 -0
  84. package/dist/src/lib/snapshot-audit-source.js.map +1 -0
  85. package/dist/src/lib/snapshot-audit-store.d.ts +52 -0
  86. package/dist/src/lib/snapshot-audit-store.js +196 -0
  87. package/dist/src/lib/snapshot-audit-store.js.map +1 -0
  88. package/dist/src/lib/snapshot-audit.d.ts +207 -0
  89. package/dist/src/lib/snapshot-audit.js +431 -0
  90. package/dist/src/lib/snapshot-audit.js.map +1 -0
  91. package/dist/src/lib/snapshot-build-rows.d.ts +60 -0
  92. package/dist/src/lib/snapshot-build-rows.js +87 -0
  93. package/dist/src/lib/snapshot-build-rows.js.map +1 -0
  94. package/dist/src/lib/snapshot-manifest-layout.d.ts +61 -0
  95. package/dist/src/lib/snapshot-manifest-layout.js +70 -0
  96. package/dist/src/lib/snapshot-manifest-layout.js.map +1 -0
  97. package/dist/src/lib/step-run-table.d.ts +43 -0
  98. package/dist/src/lib/step-run-table.js +129 -0
  99. package/dist/src/lib/step-run-table.js.map +1 -0
  100. package/dist/src/lib/swift-codegen/functionGenerator.d.ts +26 -13
  101. package/dist/src/lib/swift-codegen/functionGenerator.js +38 -27
  102. package/dist/src/lib/swift-codegen/functionGenerator.js.map +1 -1
  103. package/dist/src/lib/sync-selectors.d.ts +45 -2
  104. package/dist/src/lib/sync-selectors.js +66 -5
  105. package/dist/src/lib/sync-selectors.js.map +1 -1
  106. package/dist/src/lib/workflow-toml-validator.d.ts +9 -5
  107. package/dist/src/lib/workflow-toml-validator.js +18 -10
  108. package/dist/src/lib/workflow-toml-validator.js.map +1 -1
  109. package/package.json +2 -2
@@ -31,11 +31,13 @@
31
31
  * directory inside the tree, so the write step resolves the real path one last
32
32
  * time and refuses a destination that lands outside the real root.
33
33
  */
34
- import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync, } from "fs";
34
+ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync, } from "fs";
35
35
  import { dirname, isAbsolute, join, posix as posixPath, relative, resolve, sep, } from "path";
36
- import { stringifyConfigToml } from "./config-toml.js";
36
+ import { parseConfigToml, stringifyConfigToml } from "./config-toml.js";
37
+ import { DOCUMENT_SCHEMA_TREE_FILE, resolveDocumentSchema, } from "./function-document-types.js";
37
38
  import { SERVER_FUNCTION_TABLE } from "./config-surface.js";
38
- import { configMode, hasDeclaredTriggers, normalizeTriggerDeclaration, resolveDeclaredMode, TriggerDeclarationError, validateSigningSecretDeclaration, } from "./generated-config-surfaces.js";
39
+ import { checkCronTriggerEntry, hasDeclaredTriggers, hasRetiredDatabaseTriggerBlock, ignoredFunctionKeys, retiredDatabaseTriggerTypes, ignoredFunctionKeyWarnings, normalizeTriggerDeclaration, RETIRED_FUNCTION_RUNTIME_KEYS, TriggerDeclarationError, validateSigningSecretDeclaration, } from "./generated-config-surfaces.js";
40
+ import { canonicalJsonStringify } from "./canonical-json.js";
39
41
  import { buildPayloadFromToml, projectRecordToToml, tomlOwned } from "./config-payload.js";
40
42
  import { serializeJsonField } from "./config-json-field.js";
41
43
  import { buildFunctionBundle, FunctionBuildError, functionEnvelopeHash, FUNCTION_ENVELOPE_SCHEMA_VERSION, isDependencyPath, } from "./function-bundle.js";
@@ -81,44 +83,31 @@ export async function collectManifestForBundle(options) {
81
83
  * (`cli/src/lib/function-triggers.ts`), kept in step only by a paired-fixture
82
84
  * test; the artifact's `--check` makes drift a build failure instead.
83
85
  *
84
- * One check is the CLI's OWN and is deliberately not on that path: the cron
85
- * expression's field count. The authority for cron grammar is the server's
86
- * `src/cron-parser.ts` (`parseCron`, run at the push boundary), which the
87
- * declaration rules never call — so this is a shape precheck that turns the
88
- * commonest typo into a named local error, not a second copy of a rule.
86
+ * Nothing here is the CLI's own any more. Until #3376 this function added a
87
+ * five-field count check of its own, justified as "not a second copy of a
88
+ * rule" precisely because the declaration path never called `parseCron`. It
89
+ * calls it now — `functionTomlPreflightErrors` runs `checkCronTriggerEntry`,
90
+ * which is the server's grammar and the server's sentence — so the precheck
91
+ * was exactly the duplicate this project exists to remove, and went with the
92
+ * justification.
89
93
  */
90
- export function normalizeFunctionTriggers(functionTable, options = {}) {
91
- const declaration = normalizeTriggerDeclaration(functionTable, options);
94
+ export function normalizeFunctionTriggers(functionTable) {
95
+ const declaration = normalizeTriggerDeclaration(functionTable);
92
96
  if (!hasDeclaredTriggers(declaration))
93
97
  return null;
94
- for (const entry of declaration.cron) {
95
- const fields = entry.cron.trim().split(/\s+/);
96
- if (fields.length !== 5) {
97
- throw new TriggerDeclarationError(`cron trigger '${entry.name}': a cron expression has exactly 5 fields, ` +
98
- `and '${entry.cron}' has ${fields.length}`);
99
- }
100
- }
101
98
  return declaration;
102
99
  }
103
100
  /**
104
- * Resolve the file's mode — `mode`, its alias `durable`, or neither — through
105
- * the ONE vendored rule (#3281). A disagreement or an unknown word is a
106
- * `FunctionBuildError` naming the function, like every other preflight
107
- * refusal, so nothing is built or sent for that file.
101
+ * The retired runtime keys a `functions/<key>.toml` still carries — #3482.
102
+ *
103
+ * NOT a refusal. Through phase 4 `mode`, `durable` and a cron entry's `mode`
104
+ * are accepted and ignored, with one warning each and every other key of the
105
+ * file applied; phase 5 (#3188) refuses them. The sentences come from the one
106
+ * vendored scanner, so the warning `config push` prints is the sentence the
107
+ * server answers with.
108
108
  */
109
- export function resolveFunctionMode(functionKey, table) {
110
- const resolution = resolveDeclaredMode({
111
- mode: table?.mode,
112
- durable: table?.durable,
113
- });
114
- // `=== false` rather than `!`: the CLI compiles without strict null checks,
115
- // where truthiness does not narrow a discriminated union.
116
- if (resolution.ok === false) {
117
- // Names the file too: this is a refusal about two lines in it, and an
118
- // author fixing a half-migrated tree wants to know which file to open.
119
- throw new FunctionBuildError(`function '${functionKey}' (functions/${functionKey}.toml): ${resolution.error}`);
120
- }
121
- return resolution.mode;
109
+ export function functionTomlIgnoredKeyWarnings(options) {
110
+ return ignoredFunctionKeyWarnings(`functions/${options.functionKey}.toml`, options.tomlData?.function);
122
111
  }
123
112
  /** The `entry` refusal, or null. One spelling, two readers. */
124
113
  function functionEntryError(functionKey, table) {
@@ -157,18 +146,8 @@ export function functionTomlPreflightErrors(options) {
157
146
  const entryError = functionEntryError(functionKey, table);
158
147
  if (entryError)
159
148
  errors.push(entryError);
160
- // The mode is what the trigger rules are checked AGAINST, so a file whose
161
- // two spellings disagree cannot be checked further — report that and stop.
162
- let mode;
163
149
  try {
164
- mode = resolveFunctionMode(functionKey, table);
165
- }
166
- catch (err) {
167
- errors.push(String(err?.message ?? err));
168
- return errors;
169
- }
170
- try {
171
- const declaration = normalizeFunctionTriggers(table, { mode });
150
+ const declaration = normalizeFunctionTriggers(table);
172
151
  const webhook = declaration?.webhook;
173
152
  if (webhook) {
174
153
  // The file-decidable half of the webhook rule: which schemes need a
@@ -183,6 +162,18 @@ export function functionTomlPreflightErrors(options) {
183
162
  errors.push(`function '${functionKey}': ${secretError.message}`);
184
163
  }
185
164
  }
165
+ // #3376 — cron expression and timezone, the server's own grammar and
166
+ // `Intl` probe, vendored. One message per broken entry rather than the
167
+ // first: the rule answers rather than throwing, so the file's errors are
168
+ // enumerated the way every other preflight rule enumerates them. The
169
+ // FIRST is byte-identical to what the push boundary would have answered,
170
+ // which is the only one the server ever gets to.
171
+ for (const entry of declaration?.cron ?? []) {
172
+ const refusal = checkCronTriggerEntry(entry);
173
+ if (refusal) {
174
+ errors.push(`function '${functionKey}': ${refusal.message}`);
175
+ }
176
+ }
186
177
  }
187
178
  catch (err) {
188
179
  // Throw-based, so the first broken rule per file wins — exactly what the
@@ -219,9 +210,6 @@ export async function buildFunctionConfigPush(options) {
219
210
  });
220
211
  if (preflight.length > 0)
221
212
  throw new FunctionBuildError(preflight[0]);
222
- // #3281 — the mode, resolved once from either spelling; everything below
223
- // reads the answer, never the keys.
224
- const mode = resolveFunctionMode(functionKey, table);
225
213
  const built = await buildFunctionBundle({ configDir, entry, functionKey });
226
214
  // #3187 — the manifest, collected by RUNNING the tree's module scope in a
227
215
  // spawned, scrubbed child process (D3187-SO-006). Fail-closed: a push whose
@@ -247,6 +235,8 @@ export async function buildFunctionConfigPush(options) {
247
235
  // The AUTHORED bytes. Read from disk rather than re-serialized, so the round
248
236
  // trip is byte-identical (F-009).
249
237
  const authored = readFileSync(filePath);
238
+ // #3455 — the document schema this version is pushed with.
239
+ const documentSchemaResolution = resolveDocumentSchema(configDir);
250
240
  const limits = normalizeLimits(table.limits);
251
241
  const push = {
252
242
  entry: built.entry,
@@ -259,9 +249,17 @@ export async function buildFunctionConfigPush(options) {
259
249
  contentHash: built.contentHash,
260
250
  manifest,
261
251
  envelopeHash: "",
262
- mode,
263
- durable: mode === "task",
264
252
  ...(limits ? { limits } : {}),
253
+ // #3455 — the app's declared DOCUMENT schema, by the one resolution rule
254
+ // push, pull and diff share. Carried inside the envelope, so an edit to
255
+ // `models/models.toml` changes every function's `envelopeHash` and the
256
+ // next push mints a new version of each: the consequence D3455-001 states,
257
+ // and what makes a running version's seed source knowable.
258
+ ...(documentSchemaResolution.source
259
+ ? {
260
+ documentSchema: Buffer.from(documentSchemaResolution.source.content, "utf-8").toString("base64"),
261
+ }
262
+ : {}),
265
263
  };
266
264
  push.envelopeHash = functionEnvelopeHash({
267
265
  schemaVersion: FUNCTION_ENVELOPE_SCHEMA_VERSION,
@@ -270,9 +268,95 @@ export async function buildFunctionConfigPush(options) {
270
268
  sources: push.sources,
271
269
  bundle: push.bundle,
272
270
  manifest: push.manifest,
271
+ documentSchema: push.documentSchema,
273
272
  });
274
273
  return push;
275
274
  }
275
+ /**
276
+ * The per-function failure when a server DID NOT retain the document schema it
277
+ * was sent — #3455 (D3455-010), criterion 7.
278
+ *
279
+ * The server builds its envelope from explicit known fields
280
+ * (`server-functions-controller.ts`) and answers the `envelopeHash` it stored.
281
+ * A server that predates this change drops `documentSchema` silently: it
282
+ * answers 200, the CLI reports the function pushed, first-writer seeding never
283
+ * works, and every later `config diff` reads Modified for a reason nobody can
284
+ * see. Comparing the hash it stored with the one this CLI built is the cheap
285
+ * acknowledgment that closes that.
286
+ *
287
+ * Gated on having SENT a schema, which is exactly what criterion 7 says
288
+ * ("reported successful only when the server retained the schema it was
289
+ * sent"): with nothing sent there is nothing to retain, and naming the
290
+ * document schema in a mismatch caused by something else would send an
291
+ * operator to the wrong place. A response with no `envelopeHash` at all is not
292
+ * judged — silence is not a disagreement.
293
+ */
294
+ export function documentSchemaRetentionFailure(input) {
295
+ if (!input.sentDocumentSchema)
296
+ return null;
297
+ const stored = input.storedEnvelopeHash;
298
+ if (typeof stored !== "string" || stored.length === 0)
299
+ return null;
300
+ if (stored === input.builtEnvelopeHash)
301
+ return null;
302
+ return ("server did not retain the document schema (envelope hash differs): the " +
303
+ "server needs an upgrade before first-writer seeding works");
304
+ }
305
+ export function documentSchemaDropAnswer(documentSchemaModels) {
306
+ if (Array.isArray(documentSchemaModels)) {
307
+ const models = documentSchemaModels;
308
+ return {
309
+ outcome: "drops",
310
+ message: `This push DROPS the document schema the running version ` +
311
+ `carries (${models.length > 0 ? models.join(", ") : "no models"}): ` +
312
+ `the project has no models/models.toml and the tree has no ` +
313
+ `generated copy, so a function's first write can no longer ` +
314
+ `seed a document model's metadata.`,
315
+ };
316
+ }
317
+ if (documentSchemaModels === null)
318
+ return { outcome: "none" };
319
+ return { outcome: "unsupported" };
320
+ }
321
+ /**
322
+ * Says ONCE — for the whole push, not once per function — that this push drops
323
+ * a document schema a running version carries (#3455, D3455-004).
324
+ *
325
+ * Removing `models/models.toml` and its generated tree copy is a legitimate
326
+ * edit, so it is a warning and not a refusal; but it turns first-writer seeding
327
+ * off for every document these functions touch, and that must not be silent.
328
+ *
329
+ * The state this holds is "has the run been told yet", and the rule is that
330
+ * only an ANSWER settles it. A function whose active version carries no schema
331
+ * has told us nothing about its siblings, so the next function is still asked —
332
+ * getting that backwards silences a real drop whenever a schema-less function
333
+ * happens to be pushed first, which is the order nothing in the tree controls.
334
+ * The model names live inside the version's envelope rather than on its row,
335
+ * which is why this is a read and not a field of the listing already in hand.
336
+ */
337
+ export function createDocumentSchemaDropWarner(deps) {
338
+ let settled = deps.treeCarriesSchema;
339
+ return async (functionId, activeConfigId) => {
340
+ if (settled || !activeConfigId)
341
+ return;
342
+ let detail;
343
+ try {
344
+ detail = await deps.readConfig(functionId, activeConfigId);
345
+ }
346
+ catch {
347
+ // A read that failed is not an answer either: the next function's may
348
+ // succeed, and losing the warning to one blip is the failure this
349
+ // whole rule exists to avoid.
350
+ return;
351
+ }
352
+ const answer = documentSchemaDropAnswer(detail?.documentSchemaModels);
353
+ if (answer.outcome === "none")
354
+ return;
355
+ settled = true;
356
+ if (answer.outcome === "drops")
357
+ deps.warn(answer.message);
358
+ };
359
+ }
276
360
  /** `[function.limits]` as the server stores it, or undefined when absent. */
277
361
  function normalizeLimits(value) {
278
362
  if (value === undefined || value === null)
@@ -356,11 +440,9 @@ export function serializeFunction(record, active, log = () => { }) {
356
440
  };
357
441
  if (active?.entry)
358
442
  table.entry = active.entry;
359
- // #3281 — the new word, and only for a task version: a request function is
360
- // the default and says nothing. Never `durable`: this is the one file the
361
- // CLI authors on a developer's behalf, and it must not teach the alias.
362
- if (activeVersionMode(active) === "task")
363
- table.mode = "task";
443
+ // #3482 — no `mode` and no `durable`. This is the one file the CLI authors
444
+ // on a developer's behalf, and it must not teach a key the platform ignores
445
+ // and phase 5 refuses.
364
446
  // #3182 — the capabilities are version-carried and server-derived, so the
365
447
  // fallback file has to state them or a push of it would silently drop the
366
448
  // function's declarations. `runAs` and `unscopedReads` are never written
@@ -378,15 +460,6 @@ export function serializeFunction(record, active, log = () => { }) {
378
460
  }
379
461
  return stringifyConfigToml({ function: table });
380
462
  }
381
- /**
382
- * The mode a server's config-version row is in, whichever of the two fields
383
- * this server serializes: `mode` since #3281, `durable` before it.
384
- */
385
- export function activeVersionMode(active) {
386
- if (active?.mode === "task" || active?.mode === "request")
387
- return active.mode;
388
- return configMode(active);
389
- }
390
463
  /**
391
464
  * The tree-relative spelling of `entry` that push actually sends.
392
465
  *
@@ -429,11 +502,9 @@ export function functionDiffEntity(tomlData) {
429
502
  inputSchema: table.inputSchema ?? null,
430
503
  outputSchema: table.outputSchema ?? null,
431
504
  entry: canonicalEntry(table.entry),
432
- // #3281 — the RESOLVED mode, never the raw keys: `durable = true` and
433
- // `mode = "task"` are one fact, and a legacy tree must read Synced against
434
- // a server that serializes the new word (D3281-SO-001). A file whose two
435
- // keys disagree is drift here and a refusal at push.
436
- mode: safeMode(table),
505
+ // #3482 — no `mode` key here. It said nothing about the function any more,
506
+ // and comparing a key neither side stores would report permanent drift for
507
+ // every tree that still carries the retired line.
437
508
  limits: limits && Object.keys(limits).length > 0 ? limits : null,
438
509
  // #3181 — normalized locally from the SAME rules the server applies, so a
439
510
  // trigger edit reads as a named field rather than only as the code-axis
@@ -448,18 +519,476 @@ export function functionDiffEntity(tomlData) {
448
519
  : [],
449
520
  };
450
521
  }
451
- function safeMode(table) {
452
- const resolution = resolveDeclaredMode({ mode: table?.mode, durable: table?.durable });
453
- return resolution.ok === true ? resolution.mode : null;
454
- }
455
522
  function safeTriggers(table) {
456
523
  try {
457
- return normalizeFunctionTriggers(table, { mode: safeMode(table) ?? "request" });
524
+ return normalizeFunctionTriggers(table);
458
525
  }
459
526
  catch {
460
527
  return null;
461
528
  }
462
529
  }
530
+ // ── The retired keys and blocks, stripped on the way out ─────────────────
531
+ //
532
+ // #3482 took the runtime keys out (D3482-007); #3483 takes the whole
533
+ // `[[function.triggers.database]]` block out for the same reason and under the
534
+ // same contract. The difference is only in what a line edit has to reach: a
535
+ // key is one assignment, and a block is a table header and everything under it
536
+ // until the next header.
537
+ /** The two multi-line string delimiters TOML has. */
538
+ const MULTILINE_DELIMITERS = ['"""', "'''"];
539
+ /**
540
+ * Remove `mode`, `durable` and every cron entry's `mode` from authored TOML,
541
+ * preserving every other byte.
542
+ *
543
+ * The issue says `config pull` never writes them, and the pull plan writes the
544
+ * version's AUTHORED bytes verbatim — so without this a tree pulled from a
545
+ * version pushed before the retirement would carry keys phase 5 refuses, and
546
+ * could not be pushed again.
547
+ *
548
+ * ── Three jobs, kept apart ────────────────────────────────────────────────
549
+ *
550
+ * The PARSE decides what is removed and what is reported: `ignoredFunctionKeys`
551
+ * is the very scanner `config push` warns with, so what a pull takes out is
552
+ * exactly what a push said it was ignoring, whatever spelling the author used.
553
+ * TOML has several — `["function"]`, `function . mode = …`, an inline
554
+ * `triggers.cron = [ { mode = … } ]` — and a scan that reads the SOURCE has to
555
+ * know every one of them or it silently leaves a key behind (CR3482-002).
556
+ *
557
+ * The LINE EDIT decides how the bytes change, and is the path every real file
558
+ * takes. A re-serialization would rewrite every comment, blank line, quoting
559
+ * style and key order in a file a human wrote, so a pull would report a hundred
560
+ * changes to make three. Here exactly the matched assignments go and nothing
561
+ * else moves.
562
+ *
563
+ * The VERIFICATION decides whether the line edit was good enough, by parsing
564
+ * what it produced: the result must parse, must carry no retired key, and must
565
+ * otherwise say precisely what the file said. That is the contract stated as a
566
+ * check rather than as a hope — and it is what makes the line edit safe to keep
567
+ * narrow. A file it cannot reach by whole lines (a retired key sharing its line
568
+ * with keys that must survive, inside an inline table) falls back to a
569
+ * serialized rewrite, which is reported, because a pulled file that still
570
+ * carries the key — or one that no longer parses (CR3482-001) — is worse than a
571
+ * reformatted one.
572
+ *
573
+ * Returns the text and one line per removal, so the caller can report what it
574
+ * did rather than silently editing a developer's file.
575
+ */
576
+ export function stripRetiredFunctionKeys(toml, label = "") {
577
+ const prefix = label ? `${label}: ` : "";
578
+ let before;
579
+ try {
580
+ before = parseConfigToml(toml);
581
+ }
582
+ catch {
583
+ // Not ours to fix, and not ours to guess at. A file that does not parse
584
+ // never pushed, so it is not a stored version's authored bytes; leave
585
+ // every byte where it is.
586
+ return { toml, removals: [] };
587
+ }
588
+ const functionTable = before.function;
589
+ const ignored = ignoredFunctionKeys(functionTable);
590
+ // PRESENCE, not entry count (CR3483-001). `database = []` names no type, and
591
+ // deciding by the list's length left that line in the pulled file — which
592
+ // the next push then refused, because the refusal reads presence. The two
593
+ // halves ask the same question now.
594
+ const hasDatabaseBlock = hasRetiredDatabaseTriggerBlock(functionTable);
595
+ const databaseTypes = retiredDatabaseTriggerTypes(functionTable);
596
+ if (ignored.length === 0 && !hasDatabaseBlock) {
597
+ return { toml, removals: [] };
598
+ }
599
+ const removals = [
600
+ ...ignored.map((key) => `${prefix}removed \`${removalPhrase(key)}\` (#3482)`),
601
+ // One line per entry, and one line for a block that has no entry to name:
602
+ // an author who wrote `triggers.database = []` still has a line gone from
603
+ // their file and is owed the same notice as one who wrote a type.
604
+ ...(hasDatabaseBlock && databaseTypes.length === 0
605
+ ? [`${prefix}removed \`[[function.triggers.database]]\` (#3483)`]
606
+ : databaseTypes.map((type) => `${prefix}removed \`[[function.triggers.database]]\` type ` +
607
+ `'${type}' (#3483)`)),
608
+ ];
609
+ const want = withoutRetiredFunctionKeys(before);
610
+ const edited = removeRetiredKeyLines(toml);
611
+ if (saysExactly(edited, want))
612
+ return { toml: edited, removals };
613
+ // The line edit could not reach it. Today that means one thing: a retired key
614
+ // written inside an inline table, sharing its line with keys that have to
615
+ // survive it.
616
+ const rewritten = serializeWithout(want);
617
+ if (rewritten !== null && saysExactly(rewritten, want)) {
618
+ return {
619
+ toml: rewritten,
620
+ removals: [
621
+ ...removals,
622
+ `${prefix}rewrote the file to do it — the key shared a line with keys ` +
623
+ `that had to stay, so comments and formatting were not preserved (#3482)`,
624
+ ],
625
+ };
626
+ }
627
+ // Unreachable from any file that parsed: `want` came from this document, so
628
+ // TOML can write it back. If it ever happens, say so and change nothing —
629
+ // a file left as it was can still be edited by hand, and one written back
630
+ // broken cannot be pushed at all.
631
+ const named = [
632
+ ...ignored.map((key) => `\`${removalPhrase(key)}\``),
633
+ ...(databaseTypes.length > 0
634
+ ? ["`[[function.triggers.database]]`"]
635
+ : []),
636
+ ].join(", ");
637
+ return {
638
+ toml,
639
+ removals: [
640
+ `${prefix}could NOT remove ${named} — the file is unchanged; delete by ` +
641
+ `hand (#3482, #3483)`,
642
+ ],
643
+ };
644
+ }
645
+ /** How a pull names one retired key it took out. */
646
+ function removalPhrase(key) {
647
+ return key.path === "mode" || key.path === "durable"
648
+ ? key.path
649
+ : "mode on a [[function.triggers.cron]] entry";
650
+ }
651
+ /** The document without the retired keys — the answer a pull has to produce. */
652
+ function withoutRetiredFunctionKeys(doc) {
653
+ const fn = doc.function;
654
+ if (!fn || typeof fn !== "object" || Array.isArray(fn))
655
+ return doc;
656
+ const nextFunction = { ...fn };
657
+ for (const key of RETIRED_FUNCTION_RUNTIME_KEYS)
658
+ delete nextFunction[key];
659
+ const triggers = nextFunction.triggers;
660
+ if (triggers && typeof triggers === "object" && !Array.isArray(triggers)) {
661
+ // One pass over the table, so the two removals cannot undo each other: a
662
+ // second `{ ...triggers, … }` built from the ORIGINAL would put back the
663
+ // block the first one took out.
664
+ const nextTriggers = {
665
+ ...triggers,
666
+ };
667
+ // #3483 — the whole block, whatever spelling it took.
668
+ delete nextTriggers.database;
669
+ const cron = triggers.cron;
670
+ const withoutMode = (entry) => {
671
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
672
+ return entry;
673
+ }
674
+ const next = { ...entry };
675
+ delete next.mode;
676
+ return next;
677
+ };
678
+ if (Array.isArray(cron)) {
679
+ nextTriggers.cron = cron.map(withoutMode);
680
+ }
681
+ else if (cron && typeof cron === "object") {
682
+ nextTriggers.cron = withoutMode(cron);
683
+ }
684
+ nextFunction.triggers = nextTriggers;
685
+ }
686
+ return { ...doc, function: nextFunction };
687
+ }
688
+ /**
689
+ * Whether `text` says precisely what `want` says.
690
+ *
691
+ * Empty tables are ignored on both sides: removing the last key of a table
692
+ * removes the table with it in the dotted spelling (`function.mode = …` at the
693
+ * document root leaves no `[function]` behind), which is the right answer and
694
+ * the one the strip has always given.
695
+ */
696
+ function saysExactly(text, want) {
697
+ try {
698
+ const parsed = parseConfigToml(text);
699
+ const fn = parsed.function;
700
+ if (ignoredFunctionKeys(fn).length)
701
+ return false;
702
+ if (hasRetiredDatabaseTriggerBlock(fn))
703
+ return false;
704
+ return withoutEmptyTables(parsed) === withoutEmptyTables(want);
705
+ }
706
+ catch {
707
+ return false;
708
+ }
709
+ }
710
+ /** Canonical JSON with empty tables dropped, for comparing two documents. */
711
+ function withoutEmptyTables(value) {
712
+ const prune = (node) => {
713
+ if (Array.isArray(node))
714
+ return node.map(prune);
715
+ if (!node || typeof node !== "object")
716
+ return node;
717
+ if (typeof node.toJSON === "function")
718
+ return node;
719
+ const next = {};
720
+ for (const [key, child] of Object.entries(node)) {
721
+ const pruned = prune(child);
722
+ const isEmptyTable = pruned !== null &&
723
+ typeof pruned === "object" &&
724
+ !Array.isArray(pruned) &&
725
+ typeof pruned.toJSON !== "function" &&
726
+ Object.keys(pruned).length === 0;
727
+ if (!isEmptyTable)
728
+ next[key] = pruned;
729
+ }
730
+ return next;
731
+ };
732
+ return canonicalJsonStringify(prune(value));
733
+ }
734
+ /** `want` written back as TOML, or null if TOML cannot carry some value of it. */
735
+ function serializeWithout(want) {
736
+ try {
737
+ const text = stringifyConfigToml(want);
738
+ return text.endsWith("\n") ? text : `${text}\n`;
739
+ }
740
+ catch {
741
+ return null;
742
+ }
743
+ }
744
+ /**
745
+ * Delete every line a retired key's assignment occupies, and nothing else.
746
+ *
747
+ * Narrow on purpose, because `mode` is a perfectly good key of some OTHER table
748
+ * (an integration's `bodyMode`, a nested table of the author's own) and inside
749
+ * a multi-line string or an array's value it is not a key at all. A key matches
750
+ * only when the table it sits in and its own dotted name together spell
751
+ * `function.mode`, `function.durable` or `function.triggers.cron.mode` — one
752
+ * comparison that covers `[function]` + `mode`, the root's `function.mode`, a
753
+ * `[[function.triggers.cron]]` entry's `mode`, and every quoted or
754
+ * space-padded spelling of each.
755
+ *
756
+ * Telling a key from text is `scanLine`'s job, and it has to be told properly
757
+ * rather than by looking for delimiters with `indexOf`: a `"""` inside a
758
+ * comment or a quoted value opens nothing, an escaped `\"""` inside a basic
759
+ * string closes nothing, and a value may run past its first line — as a
760
+ * multi-line string, an array or an inline table — in which case the WHOLE
761
+ * assignment goes, because deleting its opening line alone would orphan the
762
+ * rest and leave a file that no longer parses.
763
+ */
764
+ function removeRetiredKeyLines(toml) {
765
+ const lines = toml.split("\n");
766
+ const kept = [];
767
+ // The table the lines below belong to, by its header. The root is [].
768
+ let table = [];
769
+ let state = { open: null, depth: 0 };
770
+ // Whether the value we are still reading belongs to a key we removed, so its
771
+ // remaining lines go with it.
772
+ let droppingValue = false;
773
+ // #3483 — whether the TABLE the lines below belong to is one we removed, so
774
+ // the whole section goes with its header. A key is one assignment; a block
775
+ // is a header and everything under it until the next header.
776
+ let droppingTable = false;
777
+ for (const line of lines) {
778
+ if (state.open || state.depth > 0) {
779
+ state = scanLine(line, state);
780
+ if (!droppingValue && !droppingTable)
781
+ kept.push(line);
782
+ if (!state.open && state.depth <= 0)
783
+ droppingValue = false;
784
+ continue;
785
+ }
786
+ const trimmed = line.trim();
787
+ const header = /^\[\[?\s*([^\]]+?)\s*\]\]?\s*(#.*)?$/.exec(trimmed);
788
+ if (header) {
789
+ table = keyPath(header[1]) ?? table;
790
+ droppingTable = isRetiredTablePath(table);
791
+ if (droppingTable) {
792
+ // The blank lines that separated this block from what came before it
793
+ // separated nothing once it is gone.
794
+ while (kept.length > 0 && kept[kept.length - 1].trim() === "") {
795
+ kept.pop();
796
+ }
797
+ continue;
798
+ }
799
+ kept.push(line);
800
+ continue;
801
+ }
802
+ if (droppingTable) {
803
+ // Still inside the removed section: the line goes, and a value that runs
804
+ // past its own line goes with it.
805
+ state = scanLine(line, { open: null, depth: 0 });
806
+ continue;
807
+ }
808
+ if (isRetiredKeyLine(table, trimmed)) {
809
+ // `mode = """\ntask"""` is a retired key like any other, and one line of
810
+ // it is not.
811
+ state = scanLine(line, { open: null, depth: 0 });
812
+ droppingValue = state.open !== null || state.depth > 0;
813
+ continue;
814
+ }
815
+ state = scanLine(line, { open: null, depth: 0 });
816
+ kept.push(line);
817
+ }
818
+ return kept.join("\n");
819
+ }
820
+ /** Whether this line assigns one of the retired keys. */
821
+ function isRetiredKeyLine(table, trimmed) {
822
+ const key = assignmentKey(trimmed);
823
+ if (!key)
824
+ return false;
825
+ const path = [...table, ...key].join(".");
826
+ return (path === "function.mode" ||
827
+ path === "function.durable" ||
828
+ path === "function.triggers.cron.mode" ||
829
+ // #3483 — the inline spelling, `triggers.database = [ { … } ]` under
830
+ // `[function]`, or `function.triggers.database = …` at the root. Its value
831
+ // runs past its own line as often as not, which the caller's
832
+ // `droppingValue` handles exactly as it does for a multi-line `mode`.
833
+ path === "function.triggers.database");
834
+ }
835
+ /**
836
+ * Whether a table header names a section the strip removes whole — #3483.
837
+ *
838
+ * Prefix-matched rather than compared, so a sub-table of an entry
839
+ * (`[function.triggers.database.filter]`, which TOML allows even though this
840
+ * platform never read one) goes with the block it belongs to instead of being
841
+ * orphaned into a file that no longer parses.
842
+ */
843
+ function isRetiredTablePath(table) {
844
+ const retired = ["function", "triggers", "database"];
845
+ if (table.length < retired.length)
846
+ return false;
847
+ return retired.every((part, index) => table[index] === part);
848
+ }
849
+ /**
850
+ * The dotted name this line assigns to, in components, or null when the line
851
+ * assigns nothing.
852
+ *
853
+ * The `=` is found by scanning rather than by a regex, so a quoted key holding
854
+ * one (`"a=b".mode = 1`) is read as the key it is.
855
+ */
856
+ function assignmentKey(trimmed) {
857
+ let i = 0;
858
+ while (i < trimmed.length) {
859
+ const char = trimmed[i];
860
+ // The rest of the line is the author's prose, so there is no key on it.
861
+ if (char === "#")
862
+ return null;
863
+ if (char === '"' || char === "'") {
864
+ i = endOfSingleLineString(trimmed, i);
865
+ continue;
866
+ }
867
+ if (char === "=")
868
+ break;
869
+ i += 1;
870
+ }
871
+ if (trimmed[i] !== "=")
872
+ return null;
873
+ const raw = trimmed.slice(0, i).trim();
874
+ return raw ? keyPath(raw) : null;
875
+ }
876
+ /**
877
+ * A TOML key — a table header's inside or an assignment's left side — split
878
+ * into its components, with quotes and the whitespace TOML allows around the
879
+ * dots taken off. Null for anything this does not understand, so a key the
880
+ * strip cannot read is a key it does not delete.
881
+ */
882
+ function keyPath(raw) {
883
+ const parts = [];
884
+ let current = "";
885
+ let i = 0;
886
+ while (i < raw.length) {
887
+ const char = raw[i];
888
+ if (char === " " || char === "\t") {
889
+ i += 1;
890
+ continue;
891
+ }
892
+ if (char === '"' || char === "'") {
893
+ const end = endOfSingleLineString(raw, i);
894
+ current += raw.slice(i + 1, Math.max(i + 1, end - 1));
895
+ i = end;
896
+ continue;
897
+ }
898
+ if (char === ".") {
899
+ parts.push(current);
900
+ current = "";
901
+ i += 1;
902
+ continue;
903
+ }
904
+ if (!/[A-Za-z0-9_-]/.test(char))
905
+ return null;
906
+ current += char;
907
+ i += 1;
908
+ }
909
+ parts.push(current);
910
+ return parts;
911
+ }
912
+ /**
913
+ * Walk one line of TOML from the state it starts in to the one the next line
914
+ * starts in.
915
+ *
916
+ * Not a parser: the strip only needs to tell code from text and to know when a
917
+ * value has ended, which is the whole of what it kept getting wrong. Four rules
918
+ * carry it:
919
+ *
920
+ * - a `#` outside a string comments out the rest of the line, so a `"""` in
921
+ * an author's note opens nothing and a `]` in one closes nothing;
922
+ * - a single-line string is skipped whole, so a `"""` quoted inside one opens
923
+ * nothing either;
924
+ * - inside a BASIC string (`"""`) a backslash escapes the next character, so
925
+ * the `\"""` TOML requires for a literal triple quote does not close it. A
926
+ * literal string (`'''`) has no escapes and ends at its first delimiter;
927
+ * - brackets outside strings nest, so an array or inline table spread over
928
+ * several lines is read to its end — and the lines inside it are read as
929
+ * the value they are, never as keys of the enclosing table.
930
+ */
931
+ function scanLine(line, from) {
932
+ let open = from.open;
933
+ let depth = from.depth;
934
+ let i = 0;
935
+ while (i < line.length) {
936
+ if (open) {
937
+ if (open === '"""' && line[i] === "\\") {
938
+ i += 2;
939
+ continue;
940
+ }
941
+ if (line.startsWith(open, i)) {
942
+ i += open.length;
943
+ open = null;
944
+ continue;
945
+ }
946
+ i += 1;
947
+ continue;
948
+ }
949
+ const char = line[i];
950
+ // The rest of the line is the author's prose.
951
+ if (char === "#")
952
+ break;
953
+ if (char === '"' || char === "'") {
954
+ const delimiter = MULTILINE_DELIMITERS.find((candidate) => line.startsWith(candidate, i));
955
+ if (delimiter) {
956
+ open = delimiter;
957
+ i += delimiter.length;
958
+ continue;
959
+ }
960
+ i = endOfSingleLineString(line, i);
961
+ continue;
962
+ }
963
+ if (char === "[" || char === "{")
964
+ depth += 1;
965
+ else if (char === "]" || char === "}")
966
+ depth = Math.max(0, depth - 1);
967
+ i += 1;
968
+ }
969
+ return { open, depth };
970
+ }
971
+ /**
972
+ * The index just past the single-line string opening at `start`.
973
+ *
974
+ * An unterminated one answers the end of the line: TOML would refuse such a
975
+ * file anyway, and the strip's business is to leave it as it found it, not to
976
+ * start deleting on the strength of a quote somebody forgot to close.
977
+ */
978
+ function endOfSingleLineString(line, start) {
979
+ const quote = line[start];
980
+ let i = start + 1;
981
+ while (i < line.length) {
982
+ if (quote === '"' && line[i] === "\\") {
983
+ i += 2;
984
+ continue;
985
+ }
986
+ if (line[i] === quote)
987
+ return i + 1;
988
+ i += 1;
989
+ }
990
+ return line.length;
991
+ }
463
992
  /**
464
993
  * Why a stored source path may not be written, or null.
465
994
  *
@@ -491,17 +1020,51 @@ export function unsafePulledPath(path) {
491
1020
  */
492
1021
  export function planFunctionPull(options) {
493
1022
  const { configDir, functionKey, envelope } = options;
1023
+ const includeDocumentSchema = options.includeDocumentSchema !== false;
494
1024
  const root = resolve(configDir);
495
1025
  const files = [];
496
1026
  const sourcePaths = [];
497
1027
  const refusals = [];
1028
+ const notes = [];
1029
+ const removals = [];
498
1030
  if (typeof envelope?.toml === "string") {
499
1031
  const relativePath = `functions/${functionKey}.toml`;
1032
+ // #3482 (D3482-007) — the retired runtime keys never reach the tree. Every
1033
+ // other byte of the authored file is written back unchanged, so a pull is
1034
+ // still a byte-identical round trip for a file that carries none of them.
1035
+ // For a file that does, the consequence is one-time and recorded: the
1036
+ // pulled tree differs from the stored envelope, `config diff` reads
1037
+ // Modified, and the next push makes a version whose TOML no longer carries
1038
+ // the key.
1039
+ const stripped = stripRetiredFunctionKeys(Buffer.from(envelope.toml, "base64").toString("utf-8"), relativePath);
1040
+ notes.push(...stripped.removals);
500
1041
  files.push({
501
1042
  relative: relativePath,
502
1043
  absolute: join(root, "functions", `${functionKey}.toml`),
503
- content: Buffer.from(envelope.toml, "base64"),
1044
+ content: Buffer.from(stripped.toml, "utf-8"),
1045
+ });
1046
+ }
1047
+ // #3455 (D3455-009) — the active version's document schema, written back as
1048
+ // the tree's GENERATED copy so a fresh pull into a project with no
1049
+ // `models/models.toml` still rebuilds the same envelope and reads Synced.
1050
+ // Always at this one fixed tree path: it is derived from the envelope's
1051
+ // field, never from a stored path, so it cannot be aimed anywhere.
1052
+ if (includeDocumentSchema) {
1053
+ const copy = planDocumentSchemaPull({
1054
+ configDir,
1055
+ functions: [
1056
+ {
1057
+ functionKey,
1058
+ documentSchema: typeof envelope?.documentSchema === "string" && envelope.documentSchema
1059
+ ? envelope.documentSchema
1060
+ : null,
1061
+ },
1062
+ ],
1063
+ coversEveryFunction: true,
504
1064
  });
1065
+ files.push(...copy.files);
1066
+ removals.push(...copy.removals);
1067
+ refusals.push(...copy.warnings);
505
1068
  }
506
1069
  for (const source of envelope?.sources ?? []) {
507
1070
  const path = toPosix(String(source?.path ?? ""));
@@ -525,7 +1088,79 @@ export function planFunctionPull(options) {
525
1088
  });
526
1089
  sourcePaths.push(path);
527
1090
  }
528
- return { configDir: root, functionKey, files, sourcePaths, refusals };
1091
+ return {
1092
+ configDir: root,
1093
+ functionKey,
1094
+ files,
1095
+ sourcePaths,
1096
+ refusals,
1097
+ notes,
1098
+ removals,
1099
+ };
1100
+ }
1101
+ /**
1102
+ * The tree's generated document-schema copy, decided ONCE for a whole pull —
1103
+ * CR3455-001.
1104
+ *
1105
+ * Push and diff resolve the copy tree-wide (`resolveDocumentSchema`), so the
1106
+ * tree can hold exactly one schema however many functions it has. Deciding it
1107
+ * per function meant the last function applied won: a version carrying no
1108
+ * schema DELETED the copy a schema-bearing sibling had just written, the
1109
+ * sibling then diffed Modified for ever, and a repush from that tree dropped
1110
+ * the schema — the one outcome D3455-009 exists to prevent.
1111
+ *
1112
+ * The rules, and what each is protecting:
1113
+ *
1114
+ * - Any pulled version carrying a schema WINS over one carrying none. The
1115
+ * copy is a fallback consulted only when the project has no
1116
+ * `models/models.toml`; keeping it is what lets a fresh pull re-push.
1117
+ * - The copy is removed only when the pull read EVERY active function and
1118
+ * none carried a schema. A selective pull has not learned anything about
1119
+ * the functions it did not read, and deleting on that evidence would take
1120
+ * the schema away from a version that still pins one.
1121
+ * - Versions that disagree are reported rather than silently reconciled. One
1122
+ * file cannot reproduce two envelopes, so an operator is told which
1123
+ * function the tree now speaks for and which ones will read Modified until
1124
+ * they are pushed again.
1125
+ */
1126
+ export function planDocumentSchemaPull(options) {
1127
+ const root = resolve(options.configDir);
1128
+ const relativePath = `functions/${DOCUMENT_SCHEMA_TREE_FILE}`;
1129
+ const absolute = join(root, "functions", DOCUMENT_SCHEMA_TREE_FILE);
1130
+ const plan = {
1131
+ configDir: root,
1132
+ files: [],
1133
+ removals: [],
1134
+ warnings: [],
1135
+ };
1136
+ // Sorted by key so the schema a mixed tree keeps is a property of the app,
1137
+ // not of the order the listing happened to come back in.
1138
+ const pulled = [...options.functions].sort((a, b) => a.functionKey.localeCompare(b.functionKey));
1139
+ if (pulled.length === 0)
1140
+ return plan;
1141
+ const carriers = pulled.filter((f) => !!f.documentSchema);
1142
+ if (carriers.length === 0) {
1143
+ if (options.coversEveryFunction) {
1144
+ plan.removals.push({ relative: relativePath, absolute });
1145
+ }
1146
+ return plan;
1147
+ }
1148
+ const chosen = carriers[0];
1149
+ plan.files.push({
1150
+ relative: relativePath,
1151
+ absolute,
1152
+ content: Buffer.from(String(chosen.documentSchema), "base64"),
1153
+ });
1154
+ const disagreeing = pulled
1155
+ .filter((f) => f.documentSchema !== chosen.documentSchema)
1156
+ .map((f) => f.functionKey);
1157
+ if (disagreeing.length > 0) {
1158
+ plan.warnings.push(`${relativePath} holds the document schema '${chosen.functionKey}' is ` +
1159
+ `pinned to; ${disagreeing.join(", ")} pinned a different one or none, ` +
1160
+ "so this tree cannot rebuild every active version's envelope. " +
1161
+ "`config diff` reads those functions Modified until they are pushed again.");
1162
+ }
1163
+ return plan;
529
1164
  }
530
1165
  /**
531
1166
  * Why a destination may not be written even though its stored path is
@@ -585,7 +1220,7 @@ export function applyFunctionPull(plan) {
585
1220
  catch {
586
1221
  realRoot = plan.configDir;
587
1222
  }
588
- const sourcePaths = new Set(plan.sourcePaths);
1223
+ const sourcePaths = new Set(plan.sourcePaths ?? []);
589
1224
  for (const file of plan.files) {
590
1225
  const refusal = unsafeDestination(realRoot, file.absolute);
591
1226
  if (refusal) {
@@ -599,6 +1234,25 @@ export function applyFunctionPull(plan) {
599
1234
  if (sourcePaths.has(file.relative))
600
1235
  outcome.writtenSources.push(file.relative);
601
1236
  }
1237
+ // #3455 — a stale generated schema copy, when the active version carries
1238
+ // none. Same real-path check as a write: the path is this CLI's own
1239
+ // constant, but a symlinked `functions/` must not let a pull unlink
1240
+ // something outside the tree.
1241
+ for (const removal of plan.removals) {
1242
+ if (!existsSync(removal.absolute))
1243
+ continue;
1244
+ const refusal = unsafeDestination(realRoot, removal.absolute);
1245
+ if (refusal) {
1246
+ outcome.refusals.push(refusal);
1247
+ continue;
1248
+ }
1249
+ try {
1250
+ rmSync(removal.absolute);
1251
+ }
1252
+ catch (error) {
1253
+ outcome.refusals.push(`failed removing '${removal.relative}': ${String(error?.message ?? error)}`);
1254
+ }
1255
+ }
602
1256
  return outcome;
603
1257
  }
604
1258
  //# sourceMappingURL=function-sync.js.map