primitive-admin 1.1.0-alpha.72 → 1.1.0-alpha.74
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.
- package/README.md +18 -25
- package/assets/skill/skills/primitive-platform/SKILL.md +30 -14
- package/dist/bin/primitive.js +14 -11
- package/dist/bin/primitive.js.map +1 -1
- package/dist/src/commands/analytics.js +117 -2
- package/dist/src/commands/analytics.js.map +1 -1
- package/dist/src/commands/auth.js +13 -107
- package/dist/src/commands/auth.js.map +1 -1
- package/dist/src/commands/collection-type-configs.js +1 -1
- package/dist/src/commands/collection-type-configs.js.map +1 -1
- package/dist/src/commands/database-type-configs.js +1 -1
- package/dist/src/commands/database-type-configs.js.map +1 -1
- package/dist/src/commands/databases.js +1 -1
- package/dist/src/commands/databases.js.map +1 -1
- package/dist/src/commands/documents.js +749 -29
- package/dist/src/commands/documents.js.map +1 -1
- package/dist/src/commands/env.js +44 -6
- package/dist/src/commands/env.js.map +1 -1
- package/dist/src/commands/functions.d.ts +20 -0
- package/dist/src/commands/functions.js +329 -0
- package/dist/src/commands/functions.js.map +1 -0
- package/dist/src/commands/groups.js +1 -1
- package/dist/src/commands/groups.js.map +1 -1
- package/dist/src/commands/init.js +2 -15
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/integrations.js +5 -3
- package/dist/src/commands/integrations.js.map +1 -1
- package/dist/src/commands/prompts.js +5 -7
- package/dist/src/commands/prompts.js.map +1 -1
- package/dist/src/commands/rule-sets.js +1 -1
- package/dist/src/commands/rule-sets.js.map +1 -1
- package/dist/src/commands/scripts.js +6 -7
- package/dist/src/commands/scripts.js.map +1 -1
- package/dist/src/commands/sync.d.ts +63 -0
- package/dist/src/commands/sync.js +650 -2
- package/dist/src/commands/sync.js.map +1 -1
- package/dist/src/commands/workflows.js +5 -7
- package/dist/src/commands/workflows.js.map +1 -1
- package/dist/src/lib/api-client.d.ts +156 -1
- package/dist/src/lib/api-client.js +219 -3
- package/dist/src/lib/api-client.js.map +1 -1
- package/dist/src/lib/app-settings-descriptor.js +9 -0
- package/dist/src/lib/app-settings-descriptor.js.map +1 -1
- package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js +5 -7
- package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js.map +1 -1
- package/dist/src/lib/config-object-descriptor.js +39 -0
- package/dist/src/lib/config-object-descriptor.js.map +1 -1
- package/dist/src/lib/config-surface.d.ts +10 -0
- package/dist/src/lib/config-surface.js +17 -0
- package/dist/src/lib/config-surface.js.map +1 -1
- package/dist/src/lib/config.d.ts +13 -2
- package/dist/src/lib/config.js +20 -37
- package/dist/src/lib/config.js.map +1 -1
- package/dist/src/lib/credentials-store.d.ts +4 -12
- package/dist/src/lib/credentials-store.js +49 -82
- package/dist/src/lib/credentials-store.js.map +1 -1
- package/dist/src/lib/env-resolver-core.d.ts +23 -7
- package/dist/src/lib/env-resolver-core.js +44 -4
- package/dist/src/lib/env-resolver-core.js.map +1 -1
- package/dist/src/lib/function-bundle.d.ts +131 -0
- package/dist/src/lib/function-bundle.js +321 -0
- package/dist/src/lib/function-bundle.js.map +1 -0
- package/dist/src/lib/function-sync.d.ts +157 -0
- package/dist/src/lib/function-sync.js +371 -0
- package/dist/src/lib/function-sync.js.map +1 -0
- package/dist/src/lib/function-triggers.d.ts +54 -0
- package/dist/src/lib/function-triggers.js +223 -0
- package/dist/src/lib/function-triggers.js.map +1 -0
- package/dist/src/lib/generated-config-surfaces.d.ts +1 -0
- package/dist/src/lib/generated-config-surfaces.js +298 -1
- package/dist/src/lib/generated-config-surfaces.js.map +1 -1
- package/dist/src/lib/init-ios-links.d.ts +6 -5
- package/dist/src/lib/init-ios-links.js +8 -7
- package/dist/src/lib/init-ios-links.js.map +1 -1
- package/dist/src/lib/init-xcode.d.ts +4 -3
- package/dist/src/lib/init-xcode.js +28 -5
- package/dist/src/lib/init-xcode.js.map +1 -1
- package/dist/src/lib/ios-app-id.d.ts +34 -0
- package/dist/src/lib/ios-app-id.js +69 -0
- package/dist/src/lib/ios-app-id.js.map +1 -0
- package/dist/src/lib/project-config.d.ts +15 -1
- package/dist/src/lib/project-config.js +31 -3
- package/dist/src/lib/project-config.js.map +1 -1
- package/dist/src/lib/sync-resource-types.d.ts +21 -2
- package/dist/src/lib/sync-resource-types.js +104 -6
- package/dist/src/lib/sync-resource-types.js.map +1 -1
- package/dist/src/lib/test-case-variables.d.ts +14 -0
- package/dist/src/lib/test-case-variables.js +42 -0
- package/dist/src/lib/test-case-variables.js.map +1 -1
- package/dist/src/lib/workflow-usage.d.ts +198 -0
- package/dist/src/lib/workflow-usage.js +312 -0
- package/dist/src/lib/workflow-usage.js.map +1 -0
- package/dist/src/types/index.d.ts +12 -2
- package/package.json +2 -1
|
@@ -29,9 +29,13 @@ import { buildWorkflowPayloadFromToml, parseWorkflowExpressionsToml, } from "../
|
|
|
29
29
|
// payload builder so the diff normalizer and the wire cannot disagree.
|
|
30
30
|
import { WORKFLOW_MODEL_DEFAULTS } from "../lib/workflow-defaults.js";
|
|
31
31
|
// #2644 — the one definition per configuration object, vendored from the server.
|
|
32
|
-
import { BLOB_BUCKET_TABLE, COLLECTION_TYPE_CONFIG_TABLE, CRON_TRIGGER_TABLE, DATABASE_TYPE_TABLE, EMAIL_TEMPLATE_TABLE, GROUP_TYPE_CONFIG_TABLE, INTEGRATION_TABLE, METADATA_CATEGORY_CONFIG_TABLE, PROMPT_CONFIG_TABLE, PROMPT_TABLE, RULE_SET_TABLE, TEST_CASE_SURFACE, TEST_CASE_TABLE, WEBHOOK_TABLE, WORKFLOW_TABLE, configDeclaredTypeErrors, findMisshapenTables, findUnknownDocumentKeys, findUnknownServerKeys, findUnknownTomlKeys, misshapenTableMessage, retiredConfigKey, spellsDecimalNumber, unrecognizedPullKeysMessage, unrecognizedPushKeysMessage, unrecognizedPushTablesMessage, warnUnrecognizedServerKeys, } from "../lib/config-surface.js";
|
|
32
|
+
import { BLOB_BUCKET_TABLE, COLLECTION_TYPE_CONFIG_TABLE, CRON_TRIGGER_TABLE, DATABASE_TYPE_TABLE, EMAIL_TEMPLATE_TABLE, GROUP_TYPE_CONFIG_TABLE, INTEGRATION_TABLE, METADATA_CATEGORY_CONFIG_TABLE, PROMPT_CONFIG_TABLE, PROMPT_TABLE, RULE_SET_TABLE, SERVER_FUNCTION_TABLE, TEST_CASE_SURFACE, TEST_CASE_TABLE, WEBHOOK_TABLE, WORKFLOW_TABLE, configDeclaredTypeErrors, fieldServerDefault, findMisshapenTables, findUnknownDocumentKeys, findUnknownServerKeys, findUnknownTomlKeys, misshapenTableMessage, retiredConfigKey, spellsDecimalNumber, unrecognizedPullKeysMessage, unrecognizedPushKeysMessage, unrecognizedPushTablesMessage, warnUnrecognizedServerKeys, } from "../lib/config-surface.js";
|
|
33
33
|
import { buildPayloadFromToml, projectEntityToPayload, projectRecordToToml, tomlOwned, } from "../lib/config-payload.js";
|
|
34
34
|
import { CONFIG_SURFACES, isEmptyForEmit, } from "../lib/generated-config-surfaces.js";
|
|
35
|
+
// #3179 — the function's two halves: the header payloads and the envelope
|
|
36
|
+
// `config push` sends, and the bytes `config pull` writes back.
|
|
37
|
+
import { applyFunctionPull, buildFunctionConfigPush, buildFunctionCreatePayload, buildFunctionUpdatePayload, functionDiffEntity, planFunctionPull, serializeFunction, } from "../lib/function-sync.js";
|
|
38
|
+
import { FunctionBuildError } from "../lib/function-bundle.js";
|
|
35
39
|
import { detectLayout, migrateLegacyToV2 } from "../lib/block-layout.js";
|
|
36
40
|
import { ABSENT_FROM_EXPORT, LOCAL_ONLY_ABSENT, LOCAL_ONLY_NEW, PUSH_PRUNE_SPECS, REMOTE_ONLY_UNMANAGED, REMOTE_ONLY_WILL_DELETE, SYNC_RESOURCE_TYPES, VALIDATION_ERROR, applyPrune, attachmentWritePath, attemptFetch, classifyLocalOnly, classifyPruneDeleteFailure, classifyRemoteOnly, collectPushPruneCandidates, decidePushPruneCandidate, formatPrunePlan, hasUncommittedChanges, isSafeAttachmentFilename, isSafeSyncFilename, planPrune, preserveUnwrittenEntries, removePrunedSidecar, ruleSetFileKey, } from "../lib/sync-resource-types.js";
|
|
37
41
|
import { normalizeTestCaseKey, testCaseKeyError, } from "../lib/test-case-keys.js";
|
|
@@ -1594,6 +1598,32 @@ export function buildBlobBucketUpdatePayload(bucket) {
|
|
|
1594
1598
|
}
|
|
1595
1599
|
return updatePayload;
|
|
1596
1600
|
}
|
|
1601
|
+
/**
|
|
1602
|
+
* The `[[configs]] status` the server assigns when a file names none (#3201).
|
|
1603
|
+
*
|
|
1604
|
+
* Taken from the definition, not spelled again here: pull omits the key at
|
|
1605
|
+
* this value and push sends this value for the omitted key, and those two
|
|
1606
|
+
* statements have to be the same one or a pulled file would not describe the
|
|
1607
|
+
* state it was pulled from.
|
|
1608
|
+
*/
|
|
1609
|
+
const PROMPT_CONFIG_DEFAULT_STATUS = fieldServerDefault(PROMPT_CONFIG_TABLE, "status");
|
|
1610
|
+
/**
|
|
1611
|
+
* The status a freshly CREATED prompt config still owes the file, or null
|
|
1612
|
+
* (#3201).
|
|
1613
|
+
*
|
|
1614
|
+
* `status` is update-only — `POST /prompts` and `POST /prompts/:id/configs`
|
|
1615
|
+
* both assign `active` whatever the body says — so an authored
|
|
1616
|
+
* `status = "archived"` was silently dropped on every create path: a pulled
|
|
1617
|
+
* tree pushed from a clean clone into a fresh environment brought every
|
|
1618
|
+
* retired config back into service. Read through the push payload builder, so
|
|
1619
|
+
* a file that omits the key asks for the default and needs no request.
|
|
1620
|
+
*/
|
|
1621
|
+
export function createdConfigStatusPatch(tomlConfig) {
|
|
1622
|
+
const { status } = buildPromptConfigPayload(tomlConfig, { mode: "update" });
|
|
1623
|
+
return typeof status === "string" && status !== PROMPT_CONFIG_DEFAULT_STATUS
|
|
1624
|
+
? { status }
|
|
1625
|
+
: null;
|
|
1626
|
+
}
|
|
1597
1627
|
/**
|
|
1598
1628
|
* Which `[[configs]]` entry a prompt file marks as live (issue #2645).
|
|
1599
1629
|
*
|
|
@@ -1705,6 +1735,17 @@ export function serializePrompt(prompt, logger) {
|
|
|
1705
1735
|
},
|
|
1706
1736
|
configs: (prompt.configs || []).map((config) => ({
|
|
1707
1737
|
...projectRecordToToml(PROMPT_CONFIG_TABLE, config, {
|
|
1738
|
+
// #3201 — the per-version key, written only when the config is
|
|
1739
|
+
// RETIRED. `active` is what the create handler assigns and what an
|
|
1740
|
+
// absent key means (the definition's `serverDefault`), so emitting it
|
|
1741
|
+
// put a `status = "active"` line in every pulled prompt file: the one
|
|
1742
|
+
// spelling the availability contract says a config file never carries
|
|
1743
|
+
// (#2803), on the line `config create` scaffolds the `active = true`
|
|
1744
|
+
// marker onto. A retired config still says so — that is what #2645 put
|
|
1745
|
+
// the key in the file for.
|
|
1746
|
+
status: (record) => record?.status === PROMPT_CONFIG_DEFAULT_STATUS
|
|
1747
|
+
? undefined
|
|
1748
|
+
: record?.status,
|
|
1708
1749
|
// Stored as a string on the model and returned parsed; coerce
|
|
1709
1750
|
// defensively so an older server's string still writes as a number.
|
|
1710
1751
|
temperature: (record) => record.temperature != null ? Number(record.temperature) : undefined,
|
|
@@ -1852,6 +1893,13 @@ const SEEDED_CONFIG_DEFERRED = ["outputSchema", "description"];
|
|
|
1852
1893
|
* `seededConfigName` is what the server called it — read from the create
|
|
1853
1894
|
* response rather than assumed, so a handler that one day honors the name
|
|
1854
1895
|
* produces no needless PATCH.
|
|
1896
|
+
*
|
|
1897
|
+
* #3201 — the entry's `status` is deferred the OTHER way and is deliberately
|
|
1898
|
+
* not here. The create points the prompt's `activeConfigId` at this seeded
|
|
1899
|
+
* config, so retiring it in this early patch would archive the config the
|
|
1900
|
+
* prompt is serving from and leave it there until the marked entry is created
|
|
1901
|
+
* and activated — a window a failed create or activation freezes. It is
|
|
1902
|
+
* applied after activation instead (`createdConfigStatusPatch`).
|
|
1855
1903
|
*/
|
|
1856
1904
|
export function seededOnlyPromptConfigFields(firstConfig, seededConfigName = "default") {
|
|
1857
1905
|
const full = buildPromptConfigPayload(firstConfig || {}, { mode: "update" });
|
|
@@ -3335,6 +3383,28 @@ const CONFIG_DIFF_SPECS = {
|
|
|
3335
3383
|
serialize: (record) => serializeEmailTemplate(record),
|
|
3336
3384
|
describe: (entity) => `email template "${entity?.emailType}"`,
|
|
3337
3385
|
},
|
|
3386
|
+
// #3179 — server functions. The compared entity spans the header AND the
|
|
3387
|
+
// three keys the immutable version carries (`entry`, `durable`, `limits`),
|
|
3388
|
+
// because an author writes one file and a version is never authored
|
|
3389
|
+
// separately: comparing only the header would report a changed entry point as
|
|
3390
|
+
// Synced. The SOURCE bytes are a second axis the table cannot express — they
|
|
3391
|
+
// are compared by `envelopeHash`, which is also what the server dedupes on.
|
|
3392
|
+
"function": {
|
|
3393
|
+
label: "function",
|
|
3394
|
+
table: SERVER_FUNCTION_TABLE,
|
|
3395
|
+
parse: (doc) => functionDiffEntity(doc),
|
|
3396
|
+
// `extra` is the ACTIVE config version, which is where the remote side's
|
|
3397
|
+
// entry/durable/limits live; the header row does not carry them.
|
|
3398
|
+
serialize: (record, _maps, extra) => serializeFunction(record, extra),
|
|
3399
|
+
// The version-carried keys are not fields of the header table, so they
|
|
3400
|
+
// travel as extras — present on both sides or absent on both.
|
|
3401
|
+
extras: (entity) => ({
|
|
3402
|
+
entry: entity.entry ?? null,
|
|
3403
|
+
durable: entity.durable === true,
|
|
3404
|
+
limits: entity.limits ?? null,
|
|
3405
|
+
}),
|
|
3406
|
+
describe: (entity) => `function "${entity?.functionKey}"`,
|
|
3407
|
+
},
|
|
3338
3408
|
// #2757 — webhooks join the registry comparator for the same reason email
|
|
3339
3409
|
// templates did: `config diff` reported existence only, so an edit that
|
|
3340
3410
|
// rerouted a webhook to a different `workflowKey` read as Synced while push
|
|
@@ -4594,6 +4664,123 @@ selects = () => true) {
|
|
|
4594
4664
|
: { ok: false };
|
|
4595
4665
|
return { scriptEntities, count: written, presence, listOk: true, serverKeys };
|
|
4596
4666
|
}
|
|
4667
|
+
/**
|
|
4668
|
+
* Pull server-side `ServerFunction` rows into `functions/` (#3179).
|
|
4669
|
+
*
|
|
4670
|
+
* Two things make this different from every other type's pull:
|
|
4671
|
+
*
|
|
4672
|
+
* 1. **A function is more than its file.** The active config VERSION holds an
|
|
4673
|
+
* envelope with the authored TOML bytes and every source file, so the pull
|
|
4674
|
+
* writes `functions/<key>.toml` plus the TypeScript beside it, and records
|
|
4675
|
+
* the source paths so prune can remove the code with the function.
|
|
4676
|
+
* 2. **Bytes, not a re-serialization.** The stored TOML is written verbatim,
|
|
4677
|
+
* so comments, key order and line endings survive the round trip (F-009).
|
|
4678
|
+
* A function that has never had code pushed has no stored bytes; that one
|
|
4679
|
+
* gets a canonical serialization of its header, which is still a pushable
|
|
4680
|
+
* file.
|
|
4681
|
+
*
|
|
4682
|
+
* A stored source path is never trusted: `planFunctionPull` refuses one that
|
|
4683
|
+
* normalizes outside the tree, and the refusals are reported rather than
|
|
4684
|
+
* swallowed.
|
|
4685
|
+
*/
|
|
4686
|
+
export async function pullFunctions(client, appId, configDir, logger = () => { }, selects = () => true) {
|
|
4687
|
+
const functionsDir = join(configDir, "functions");
|
|
4688
|
+
ensureDir(functionsDir);
|
|
4689
|
+
const functionEntities = {};
|
|
4690
|
+
let items = [];
|
|
4691
|
+
try {
|
|
4692
|
+
const result = await client.listFunctions(appId);
|
|
4693
|
+
items = result.items || [];
|
|
4694
|
+
}
|
|
4695
|
+
catch {
|
|
4696
|
+
return {
|
|
4697
|
+
functionEntities,
|
|
4698
|
+
count: 0,
|
|
4699
|
+
presence: { ok: false },
|
|
4700
|
+
listOk: false,
|
|
4701
|
+
serverKeys: [],
|
|
4702
|
+
items: [],
|
|
4703
|
+
};
|
|
4704
|
+
}
|
|
4705
|
+
let detailsOk = true;
|
|
4706
|
+
let written = 0;
|
|
4707
|
+
for (const record of items) {
|
|
4708
|
+
const key = record?.functionKey;
|
|
4709
|
+
if (!key)
|
|
4710
|
+
continue;
|
|
4711
|
+
if (!selects(key))
|
|
4712
|
+
continue;
|
|
4713
|
+
// #2803 — a tombstone is not exported as a file. Its key stays reserved,
|
|
4714
|
+
// and the archived-holder message is what says so on the next push.
|
|
4715
|
+
if (String(record.status ?? "active") === "archived")
|
|
4716
|
+
continue;
|
|
4717
|
+
const filePath = join(functionsDir, `${key}.toml`);
|
|
4718
|
+
let entity = {
|
|
4719
|
+
id: record.functionId,
|
|
4720
|
+
modifiedAt: record.modifiedAt || new Date().toISOString(),
|
|
4721
|
+
};
|
|
4722
|
+
if (!record.activeConfigId) {
|
|
4723
|
+
// No code pushed yet: the header is all there is to write.
|
|
4724
|
+
writeFileSync(filePath, serializeFunction(record));
|
|
4725
|
+
entity.contentHash = computeFileHash(filePath);
|
|
4726
|
+
entity.semanticHash = hashRemoteConfigForDiff(configDiffSpec("function"), record, { ruleSetIdToName: new Map(), ruleSetNameToId: new Map() });
|
|
4727
|
+
functionEntities[key] = entity;
|
|
4728
|
+
written += 1;
|
|
4729
|
+
logger(` Wrote functions/${key}.toml (no version pushed yet)`);
|
|
4730
|
+
continue;
|
|
4731
|
+
}
|
|
4732
|
+
let active;
|
|
4733
|
+
let envelope;
|
|
4734
|
+
try {
|
|
4735
|
+
const configs = await client.listFunctionConfigs(appId, record.functionId);
|
|
4736
|
+
active = (configs.items || []).find((c) => String(c.configId) === String(record.activeConfigId));
|
|
4737
|
+
if (active) {
|
|
4738
|
+
envelope = await client.getFunctionConfigEnvelope(appId, record.functionId, active.configId);
|
|
4739
|
+
}
|
|
4740
|
+
}
|
|
4741
|
+
catch {
|
|
4742
|
+
detailsOk = false;
|
|
4743
|
+
logger(` Skipped functions/${key}.toml (could not fetch its version)`);
|
|
4744
|
+
continue;
|
|
4745
|
+
}
|
|
4746
|
+
if (!active || !envelope) {
|
|
4747
|
+
detailsOk = false;
|
|
4748
|
+
logger(` Skipped functions/${key}.toml (active version not readable)`);
|
|
4749
|
+
continue;
|
|
4750
|
+
}
|
|
4751
|
+
const plan = planFunctionPull({ configDir, functionKey: key, envelope });
|
|
4752
|
+
for (const refusal of plan.refusals)
|
|
4753
|
+
logger(` ${refusal}`);
|
|
4754
|
+
const applied = applyFunctionPull(plan);
|
|
4755
|
+
for (const refusal of applied.refusals)
|
|
4756
|
+
logger(` ${refusal}`);
|
|
4757
|
+
entity.contentHash = computeFileHash(filePath);
|
|
4758
|
+
entity.semanticHash = hashRemoteConfigForDiff(configDiffSpec("function"), record, { ruleSetIdToName: new Map(), ruleSetNameToId: new Map() }, active);
|
|
4759
|
+
entity.envelopeHash = envelope.envelopeHash ?? active.envelopeHash;
|
|
4760
|
+
entity.activeConfigId = active.configId;
|
|
4761
|
+
// Only the files that were really written: a path the write step refused
|
|
4762
|
+
// is not one prune may later delete on this function's behalf.
|
|
4763
|
+
entity.sources = applied.writtenSources;
|
|
4764
|
+
functionEntities[key] = entity;
|
|
4765
|
+
written += 1;
|
|
4766
|
+
logger(` Wrote functions/${key}.toml and ${applied.writtenSources.length} source file(s)`);
|
|
4767
|
+
}
|
|
4768
|
+
const serverKeys = items
|
|
4769
|
+
.filter((f) => String(f?.status ?? "active") !== "archived")
|
|
4770
|
+
.map((f) => f?.functionKey)
|
|
4771
|
+
.filter(Boolean);
|
|
4772
|
+
const presence = detailsOk
|
|
4773
|
+
? { ok: true, serverKeys }
|
|
4774
|
+
: { ok: false };
|
|
4775
|
+
return {
|
|
4776
|
+
functionEntities,
|
|
4777
|
+
count: written,
|
|
4778
|
+
presence,
|
|
4779
|
+
listOk: true,
|
|
4780
|
+
serverKeys,
|
|
4781
|
+
items,
|
|
4782
|
+
};
|
|
4783
|
+
}
|
|
4597
4784
|
/**
|
|
4598
4785
|
* Every test case a block has, draining the cursor (#2769).
|
|
4599
4786
|
*
|
|
@@ -6117,6 +6304,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
6117
6304
|
ensureDir(join(configDir, "prompts"));
|
|
6118
6305
|
ensureDir(join(configDir, "workflows"));
|
|
6119
6306
|
ensureDir(join(configDir, "transforms")); // #892 slice 7 — Rhai scripts
|
|
6307
|
+
ensureDir(join(configDir, "functions")); // #3179 — server functions
|
|
6120
6308
|
ensureDir(join(configDir, "database-type-configs"));
|
|
6121
6309
|
ensureDir(join(configDir, "rule-sets"));
|
|
6122
6310
|
ensureDir(join(configDir, "group-type-configs"));
|
|
@@ -6380,6 +6568,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
6380
6568
|
ensureDir(join(configDir, "prompts"));
|
|
6381
6569
|
ensureDir(join(configDir, "workflows"));
|
|
6382
6570
|
ensureDir(join(configDir, "transforms")); // #892 slice 7 — Rhai scripts
|
|
6571
|
+
ensureDir(join(configDir, "functions")); // #3179 — server functions
|
|
6383
6572
|
ensureDir(join(configDir, "database-type-configs"));
|
|
6384
6573
|
ensureDir(join(configDir, "rule-sets"));
|
|
6385
6574
|
ensureDir(join(configDir, "group-type-configs"));
|
|
@@ -6735,6 +6924,20 @@ export function registerConfigSyncCommands(sync) {
|
|
|
6735
6924
|
if (pulledScriptsCount > 0) {
|
|
6736
6925
|
info(` Pulled ${pulledScriptsCount} transform(s)`);
|
|
6737
6926
|
}
|
|
6927
|
+
// Pull server functions — #3179. Writes `functions/<key>.toml` from the
|
|
6928
|
+
// AUTHORED bytes stored in the active version's envelope, plus every
|
|
6929
|
+
// source file beside it, so the round trip is byte-identical.
|
|
6930
|
+
const { functionEntities, count: pulledFunctionsCount, presence: functionPresence, listOk: functionListOk, serverKeys: functionServerKeys, items: functionTombstoneCandidates, } = await pullFunctions(client, resolvedAppId, configDir, info, (key) => {
|
|
6931
|
+
if (!pullSelection)
|
|
6932
|
+
return true;
|
|
6933
|
+
if (!selectionIncludesKey(pullSelection, "function", key))
|
|
6934
|
+
return false;
|
|
6935
|
+
matchedSelectors.add(`function/${key}`);
|
|
6936
|
+
return true;
|
|
6937
|
+
});
|
|
6938
|
+
if (pulledFunctionsCount > 0) {
|
|
6939
|
+
info(` Pulled ${pulledFunctionsCount} function(s)`);
|
|
6940
|
+
}
|
|
6738
6941
|
// Build lookup maps for test case serialization
|
|
6739
6942
|
const configIdToName = new Map();
|
|
6740
6943
|
const promptIdToKey = new Map();
|
|
@@ -7013,6 +7216,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7013
7216
|
? { ok: true, serverKeys: blobBucketItems.map((b) => b.bucketKey) }
|
|
7014
7217
|
: { ok: false },
|
|
7015
7218
|
scripts: scriptPresence,
|
|
7219
|
+
functions: functionPresence,
|
|
7016
7220
|
emailTemplates: emailTemplatePresence,
|
|
7017
7221
|
databaseTypes: databaseTypeConfigsOutcome.ok && databaseTypeDetailsOk
|
|
7018
7222
|
? { ok: true, serverKeys: databaseTypesWithOps.map((d) => d.typeConfig.databaseType) }
|
|
@@ -7079,11 +7283,36 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7079
7283
|
for (const key of plan.unsafeKeys) {
|
|
7080
7284
|
warn(` Skipped pruning ${resourceType.dir} entry "${key}" — its sync-state key does not map to a safe filename`);
|
|
7081
7285
|
}
|
|
7286
|
+
// #3179 — the source files the SURVIVORS still own. Two functions can
|
|
7287
|
+
// import the same module, and both record it; deleting it with one
|
|
7288
|
+
// would leave the other unbuildable while its state called it
|
|
7289
|
+
// current. Both the prior state and this pull's entities are read,
|
|
7290
|
+
// because a function created server-side since the last pull owns its
|
|
7291
|
+
// sources only in the fresh map.
|
|
7292
|
+
const pruningKeys = new Set(plan.keys);
|
|
7293
|
+
const retainedFiles = new Set();
|
|
7294
|
+
const collectRetained = (entities) => {
|
|
7295
|
+
for (const [entityKey, entity] of Object.entries(entities ?? {})) {
|
|
7296
|
+
if (pruningKeys.has(entityKey))
|
|
7297
|
+
continue;
|
|
7298
|
+
for (const path of (entity?.sources ?? [])) {
|
|
7299
|
+
retainedFiles.add(path);
|
|
7300
|
+
}
|
|
7301
|
+
}
|
|
7302
|
+
};
|
|
7303
|
+
collectRetained(priorEntities[resourceType.stateSlot]);
|
|
7304
|
+
if (resourceType.stateSlot === "functions") {
|
|
7305
|
+
collectRetained(functionEntities);
|
|
7306
|
+
}
|
|
7082
7307
|
const result = await applyPrune({
|
|
7083
7308
|
configDir,
|
|
7084
7309
|
resourceType,
|
|
7085
7310
|
keys: plan.keys,
|
|
7086
7311
|
testCaseEntities,
|
|
7312
|
+
// #3179 — a function's TypeScript goes with it. Only the prior sync
|
|
7313
|
+
// state knows where those files are, so it is what names them.
|
|
7314
|
+
extraFilesForKey: (key) => priorEntities[resourceType.stateSlot]?.[key]?.sources ?? [],
|
|
7315
|
+
retainedFiles,
|
|
7087
7316
|
logger: info,
|
|
7088
7317
|
});
|
|
7089
7318
|
prunedCount += result.prunedKeys.length;
|
|
@@ -7099,12 +7328,14 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7099
7328
|
// the prior entry for that one key — see `preserveUnwrittenEntries`.
|
|
7100
7329
|
const keepPrior = (ok, built, slot) => ok ? built : (priorEntities[slot] ?? built);
|
|
7101
7330
|
preserveUnwrittenEntries(scriptEntities, priorEntities.scripts, scriptServerKeys);
|
|
7331
|
+
preserveUnwrittenEntries(functionEntities, priorEntities.functions, functionServerKeys);
|
|
7102
7332
|
preserveUnwrittenEntries(emailTemplateEntities, priorEntities.emailTemplates, emailTemplatesOutcome.ok
|
|
7103
7333
|
? emailTemplateOverrides.map((t) => t.emailType)
|
|
7104
7334
|
: []);
|
|
7105
7335
|
const finalCronTriggerEntities = keepPrior(cronTriggersOutcome.ok, cronTriggerEntities, "cronTriggers");
|
|
7106
7336
|
const finalBlobBucketEntities = keepPrior(blobBucketsOutcome.ok, blobBucketEntities, "blobBuckets");
|
|
7107
7337
|
const finalScriptEntities = keepPrior(scriptListOk, scriptEntities, "scripts");
|
|
7338
|
+
const finalFunctionEntities = keepPrior(functionListOk, functionEntities, "functions");
|
|
7108
7339
|
const finalEmailTemplateEntities = keepPrior(emailTemplatesOutcome.ok, emailTemplateEntities, "emailTemplates");
|
|
7109
7340
|
const finalDatabaseTypeEntities = keepPrior(databaseTypeConfigsOutcome.ok, databaseTypeEntities, "databaseTypes");
|
|
7110
7341
|
const finalRuleSetEntities = keepPrior(ruleSetsOutcome.ok, ruleSetEntities, "ruleSets");
|
|
@@ -7133,6 +7364,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7133
7364
|
prompts: promptEntities,
|
|
7134
7365
|
workflows: workflowEntities,
|
|
7135
7366
|
scripts: finalScriptEntities,
|
|
7367
|
+
functions: finalFunctionEntities,
|
|
7136
7368
|
emailTemplates: finalEmailTemplateEntities,
|
|
7137
7369
|
databaseTypes: finalDatabaseTypeEntities,
|
|
7138
7370
|
ruleSets: finalRuleSetEntities,
|
|
@@ -7203,6 +7435,17 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7203
7435
|
idOf: (w) => w.workflowId,
|
|
7204
7436
|
}, Object.keys(priorEntities.workflows ?? {})),
|
|
7205
7437
|
],
|
|
7438
|
+
// #3179 — the sixth carrying type reaches the same place. An archived
|
|
7439
|
+
// function keeps its key in the app's SINGLE key namespace, so a
|
|
7440
|
+
// stranded tombstone blocks a workflow, a script or a webhook wanting
|
|
7441
|
+
// that key too, not just another function.
|
|
7442
|
+
[
|
|
7443
|
+
"functions",
|
|
7444
|
+
archivedTombstoneEntries(functionTombstoneCandidates, {
|
|
7445
|
+
keyOf: (f) => f.functionKey,
|
|
7446
|
+
idOf: (f) => f.functionId,
|
|
7447
|
+
}, Object.keys(priorEntities.functions ?? {})),
|
|
7448
|
+
],
|
|
7206
7449
|
]) {
|
|
7207
7450
|
const built = entitiesBySlot[slot];
|
|
7208
7451
|
const resourceType = SYNC_RESOURCE_TYPES.find((rt) => rt.stateSlot === slot);
|
|
@@ -7299,6 +7542,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7299
7542
|
prompts: promptEntities,
|
|
7300
7543
|
workflows: workflowEntities,
|
|
7301
7544
|
scripts: Object.keys(finalScriptEntities).length > 0 ? finalScriptEntities : undefined,
|
|
7545
|
+
functions: Object.keys(finalFunctionEntities).length > 0 ? finalFunctionEntities : undefined,
|
|
7302
7546
|
emailTemplates: Object.keys(finalEmailTemplateEntities).length > 0 ? finalEmailTemplateEntities : undefined,
|
|
7303
7547
|
testCases: Object.keys(testCaseEntities).length > 0 ? testCaseEntities : undefined,
|
|
7304
7548
|
databaseTypes: Object.keys(finalDatabaseTypeEntities).length > 0 ? finalDatabaseTypeEntities : undefined,
|
|
@@ -7324,6 +7568,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7324
7568
|
keyValue("Prompts", prompts.length);
|
|
7325
7569
|
keyValue("Workflows", workflows.length);
|
|
7326
7570
|
keyValue("Transforms", pulledScriptsCount);
|
|
7571
|
+
keyValue("Functions", pulledFunctionsCount);
|
|
7327
7572
|
keyValue("Email Templates", emailTemplates.length);
|
|
7328
7573
|
keyValue("Test Cases", totalTestCases);
|
|
7329
7574
|
keyValue("Database Types", databaseTypesWithOps.length);
|
|
@@ -9939,6 +10184,18 @@ export function registerConfigSyncCommands(sync) {
|
|
|
9939
10184
|
}
|
|
9940
10185
|
if (newConfig?.configId) {
|
|
9941
10186
|
promptConfigNameToId.set(`${key}#${configName}`, newConfig.configId);
|
|
10187
|
+
// #3201 — the create assigned `active`; a file that
|
|
10188
|
+
// retired this entry says so in the only mode the
|
|
10189
|
+
// server accepts the key in.
|
|
10190
|
+
const statusPatch = createdConfigStatusPatch(tomlConfig);
|
|
10191
|
+
if (statusPatch) {
|
|
10192
|
+
try {
|
|
10193
|
+
await client.updatePromptConfig(resolvedAppId, promptId, newConfig.configId, statusPatch);
|
|
10194
|
+
}
|
|
10195
|
+
catch (err) {
|
|
10196
|
+
throw wrapPromptConfigError(err, "update", key, configName);
|
|
10197
|
+
}
|
|
10198
|
+
}
|
|
9942
10199
|
}
|
|
9943
10200
|
}
|
|
9944
10201
|
}
|
|
@@ -10249,6 +10506,19 @@ export function registerConfigSyncCommands(sync) {
|
|
|
10249
10506
|
if (extraCreated?.configId) {
|
|
10250
10507
|
const configName = extraName;
|
|
10251
10508
|
promptConfigNameToId.set(`${key}#${configName}`, extraCreated.configId);
|
|
10509
|
+
// #3201 — as on the update path: the create ignored
|
|
10510
|
+
// the entry's `status`, so a retired one is applied
|
|
10511
|
+
// here or the fresh environment runs a config the
|
|
10512
|
+
// file retired.
|
|
10513
|
+
const statusPatch = createdConfigStatusPatch(extraConfig);
|
|
10514
|
+
if (statusPatch) {
|
|
10515
|
+
try {
|
|
10516
|
+
await client.updatePromptConfig(resolvedAppId, created.promptId, extraCreated.configId, statusPatch);
|
|
10517
|
+
}
|
|
10518
|
+
catch (err) {
|
|
10519
|
+
throw wrapPromptConfigError(err, "update", key, configName);
|
|
10520
|
+
}
|
|
10521
|
+
}
|
|
10252
10522
|
}
|
|
10253
10523
|
}
|
|
10254
10524
|
info(` Created ${configs.length - 1} additional config(s) for prompt: ${key}`);
|
|
@@ -10280,6 +10550,25 @@ export function registerConfigSyncCommands(sync) {
|
|
|
10280
10550
|
activated = true;
|
|
10281
10551
|
}
|
|
10282
10552
|
}
|
|
10553
|
+
// #3201 — the seeded config's own `status`, applied LAST for
|
|
10554
|
+
// the same reason activation runs after the extra creates:
|
|
10555
|
+
// the create points `activeConfigId` at this config, so a
|
|
10556
|
+
// file that retires it is asking for a state that only makes
|
|
10557
|
+
// sense once the entry it marks active exists and is live.
|
|
10558
|
+
// Retiring it earlier would leave the prompt serving from an
|
|
10559
|
+
// archived config for the length of the remaining calls, and
|
|
10560
|
+
// permanently if one of them failed.
|
|
10561
|
+
const seededStatusPatch = seededConfigId
|
|
10562
|
+
? createdConfigStatusPatch(firstConfig || {})
|
|
10563
|
+
: null;
|
|
10564
|
+
if (created?.promptId && seededConfigId && seededStatusPatch) {
|
|
10565
|
+
try {
|
|
10566
|
+
await client.updatePromptConfig(resolvedAppId, created.promptId, seededConfigId, seededStatusPatch);
|
|
10567
|
+
}
|
|
10568
|
+
catch (err) {
|
|
10569
|
+
throw wrapPromptConfigError(err, "update", key, firstConfigPatch?.configName || seededConfig?.configName);
|
|
10570
|
+
}
|
|
10571
|
+
}
|
|
10283
10572
|
// Stamp sync state ONLY after all create work (base prompt +
|
|
10284
10573
|
// every extra config) succeeded. If an extra-config create
|
|
10285
10574
|
// above threw, we never reach here, so the file hash is not
|
|
@@ -10576,6 +10865,261 @@ export function registerConfigSyncCommands(sync) {
|
|
|
10576
10865
|
}
|
|
10577
10866
|
}
|
|
10578
10867
|
}
|
|
10868
|
+
// Process server functions — #3179.
|
|
10869
|
+
//
|
|
10870
|
+
// A function pushes on TWO axes and they are deliberately independent:
|
|
10871
|
+
//
|
|
10872
|
+
// - the HEADER (`[function]`'s gate, runAs, schemas, plus the three
|
|
10873
|
+
// version-carried keys, all compared through the shared gate), and
|
|
10874
|
+
// - the CODE — the authored TOML bytes, every source file and the
|
|
10875
|
+
// built bundle, sent as one immutable config VERSION.
|
|
10876
|
+
//
|
|
10877
|
+
// Independent because a comment-only edit to a source file leaves the
|
|
10878
|
+
// header identical and must still make a version (F-003): gating the
|
|
10879
|
+
// code push on the header comparison would silently discard it. The
|
|
10880
|
+
// code axis compares `envelopeHash`, which is also what the SERVER
|
|
10881
|
+
// dedupes on, so an unchanged push makes no version on either side's
|
|
10882
|
+
// reckoning.
|
|
10883
|
+
//
|
|
10884
|
+
// The bundle is built FIRST, so a build error, a missing entry or an
|
|
10885
|
+
// import that escapes the config tree aborts this function before
|
|
10886
|
+
// anything is sent for it (behavior 12).
|
|
10887
|
+
const functionsPushDir = join(configDir, "functions");
|
|
10888
|
+
if (existsSync(functionsPushDir)) {
|
|
10889
|
+
const files = selectFiles("function", functionsPushDir, readdirSync(functionsPushDir).filter((f) => f.endsWith(".toml")));
|
|
10890
|
+
const functionSpec = configDiffSpec("function");
|
|
10891
|
+
const liveFunctions = new Map();
|
|
10892
|
+
let functionsLiveOk = true;
|
|
10893
|
+
if (files.length > 0) {
|
|
10894
|
+
try {
|
|
10895
|
+
const listed = await client.listFunctions(resolvedAppId);
|
|
10896
|
+
for (const f of (listed?.items ?? [])) {
|
|
10897
|
+
if (f?.functionKey)
|
|
10898
|
+
liveFunctions.set(f.functionKey, f);
|
|
10899
|
+
}
|
|
10900
|
+
}
|
|
10901
|
+
catch (err) {
|
|
10902
|
+
functionsLiveOk = false;
|
|
10903
|
+
warnLiveUnavailable("functions", err);
|
|
10904
|
+
}
|
|
10905
|
+
}
|
|
10906
|
+
/** The version a function currently runs, or null. */
|
|
10907
|
+
const liveActiveVersion = async (functionId, activeConfigId) => {
|
|
10908
|
+
if (!activeConfigId)
|
|
10909
|
+
return null;
|
|
10910
|
+
const { items } = await client.listFunctionConfigs(resolvedAppId, functionId);
|
|
10911
|
+
return ((items || []).find((c) => String(c.configId) === String(activeConfigId)) ?? null);
|
|
10912
|
+
};
|
|
10913
|
+
for (const file of files) {
|
|
10914
|
+
const filePath = join(functionsPushDir, file);
|
|
10915
|
+
const tomlData = parseTomlFile(filePath);
|
|
10916
|
+
const key = tomlData.function?.key || basename(file, ".toml");
|
|
10917
|
+
withDerivedIdentity(tomlData, "function", { key });
|
|
10918
|
+
const existingEntry = syncState?.entities?.functions?.[key];
|
|
10919
|
+
const liveFunction = liveFunctions.get(key);
|
|
10920
|
+
// Build first: nothing is sent for a function whose code does not
|
|
10921
|
+
// compile or whose imports leave the tree.
|
|
10922
|
+
let push;
|
|
10923
|
+
try {
|
|
10924
|
+
push = await buildFunctionConfigPush({
|
|
10925
|
+
configDir,
|
|
10926
|
+
functionKey: key,
|
|
10927
|
+
filePath,
|
|
10928
|
+
tomlData,
|
|
10929
|
+
});
|
|
10930
|
+
}
|
|
10931
|
+
catch (err) {
|
|
10932
|
+
if (!(err instanceof FunctionBuildError))
|
|
10933
|
+
throw err;
|
|
10934
|
+
applyFailures.push({
|
|
10935
|
+
type: "function",
|
|
10936
|
+
key,
|
|
10937
|
+
message: err.message,
|
|
10938
|
+
});
|
|
10939
|
+
continue;
|
|
10940
|
+
}
|
|
10941
|
+
// The active version, needed by BOTH axes: the header comparison
|
|
10942
|
+
// reads `entry`/`durable`/`limits` off it, and the code axis
|
|
10943
|
+
// compares its `envelopeHash`.
|
|
10944
|
+
let activeVersion = null;
|
|
10945
|
+
let versionOk = functionsLiveOk;
|
|
10946
|
+
if (functionsLiveOk && liveFunction?.functionId) {
|
|
10947
|
+
try {
|
|
10948
|
+
activeVersion = await liveActiveVersion(liveFunction.functionId, liveFunction.activeConfigId);
|
|
10949
|
+
}
|
|
10950
|
+
catch (err) {
|
|
10951
|
+
versionOk = false;
|
|
10952
|
+
warnLiveUnavailable(`function ${key}`, err);
|
|
10953
|
+
}
|
|
10954
|
+
}
|
|
10955
|
+
let headerAction = "skip";
|
|
10956
|
+
if (versionOk && functionsLiveOk) {
|
|
10957
|
+
const gate = decidePushForConfig({
|
|
10958
|
+
spec: functionSpec,
|
|
10959
|
+
maps: pushDiffMaps,
|
|
10960
|
+
localParsed: tomlData,
|
|
10961
|
+
live: liveFunction
|
|
10962
|
+
? {
|
|
10963
|
+
record: liveFunction,
|
|
10964
|
+
// The version-carried keys the remote side is projected
|
|
10965
|
+
// through; the header row does not hold them.
|
|
10966
|
+
extra: activeVersion ?? undefined,
|
|
10967
|
+
modifiedAt: liveFunction.modifiedAt,
|
|
10968
|
+
}
|
|
10969
|
+
: null,
|
|
10970
|
+
entry: existingEntry,
|
|
10971
|
+
localFileHash: computeFileHash(filePath),
|
|
10972
|
+
force: options.force,
|
|
10973
|
+
// The create leg adopts by key on the server's 409 (#1006), so
|
|
10974
|
+
// an untracked function the server already holds is that same
|
|
10975
|
+
// adopt reached one step earlier.
|
|
10976
|
+
adoptsUntrackedByKey: true,
|
|
10977
|
+
});
|
|
10978
|
+
if (gate.action === "drift" ||
|
|
10979
|
+
gate.action === "conflict" ||
|
|
10980
|
+
gate.action === "immutable") {
|
|
10981
|
+
recordDeclined("function", key, gate, existingEntry?.modifiedAt);
|
|
10982
|
+
continue;
|
|
10983
|
+
}
|
|
10984
|
+
headerAction = gate.action === "skip"
|
|
10985
|
+
? "skip"
|
|
10986
|
+
: liveFunction
|
|
10987
|
+
? "update"
|
|
10988
|
+
: "create";
|
|
10989
|
+
}
|
|
10990
|
+
else {
|
|
10991
|
+
const degraded = decideDegradedPush({
|
|
10992
|
+
force: options.force,
|
|
10993
|
+
storedContentHash: existingEntry?.contentHash,
|
|
10994
|
+
currentFileHash: computeFileHash(filePath),
|
|
10995
|
+
});
|
|
10996
|
+
if (degraded.action === "decline") {
|
|
10997
|
+
recordDeclined("function", key, { action: "live-unavailable" }, existingEntry?.modifiedAt);
|
|
10998
|
+
continue;
|
|
10999
|
+
}
|
|
11000
|
+
headerAction = degraded.action === "skip"
|
|
11001
|
+
? "skip"
|
|
11002
|
+
: liveFunction
|
|
11003
|
+
? "update"
|
|
11004
|
+
: "create";
|
|
11005
|
+
}
|
|
11006
|
+
// The code axis. `envelopeHash` is the server's own no-op test, so
|
|
11007
|
+
// matching it here means the call would create nothing.
|
|
11008
|
+
const codeUnchanged = !!liveFunction &&
|
|
11009
|
+
!!activeVersion &&
|
|
11010
|
+
String(activeVersion.envelopeHash ?? "") === push.envelopeHash;
|
|
11011
|
+
if (headerAction === "skip" && codeUnchanged) {
|
|
11012
|
+
skippedCount++;
|
|
11013
|
+
if (syncState && liveFunction?.functionId) {
|
|
11014
|
+
if (!syncState.entities.functions)
|
|
11015
|
+
syncState.entities.functions = {};
|
|
11016
|
+
syncState.entities.functions[key] = {
|
|
11017
|
+
...(existingEntry ?? {}),
|
|
11018
|
+
id: liveFunction.functionId,
|
|
11019
|
+
modifiedAt: liveFunction.modifiedAt ||
|
|
11020
|
+
existingEntry?.modifiedAt ||
|
|
11021
|
+
new Date().toISOString(),
|
|
11022
|
+
contentHash: computeFileHash(filePath),
|
|
11023
|
+
semanticHash: hashRemoteConfigForDiff(functionSpec, liveFunction, pushDiffMaps, activeVersion ?? undefined),
|
|
11024
|
+
envelopeHash: push.envelopeHash,
|
|
11025
|
+
activeConfigId: activeVersion?.configId,
|
|
11026
|
+
sources: push.sources.map((src) => src.path),
|
|
11027
|
+
};
|
|
11028
|
+
}
|
|
11029
|
+
continue;
|
|
11030
|
+
}
|
|
11031
|
+
changes.push({
|
|
11032
|
+
type: "function",
|
|
11033
|
+
action: liveFunction ? "update" : "create",
|
|
11034
|
+
key,
|
|
11035
|
+
});
|
|
11036
|
+
if (options.dryRun)
|
|
11037
|
+
continue;
|
|
11038
|
+
// Every server refusal below is recorded against THIS function and
|
|
11039
|
+
// the loop moves on. A single archived tombstone — or a key the
|
|
11040
|
+
// app's namespace already holds — must not abort a push carrying
|
|
11041
|
+
// unrelated changes: the run reports what it could not apply, exits
|
|
11042
|
+
// non-zero, and everything else in the tree still lands. That is
|
|
11043
|
+
// the same contract `applyFailures` gives every other type; a throw
|
|
11044
|
+
// here would let one refusal block a whole repository.
|
|
11045
|
+
let functionId = liveFunction?.functionId ?? existingEntry?.id;
|
|
11046
|
+
let pushedVersion;
|
|
11047
|
+
try {
|
|
11048
|
+
if (!functionId) {
|
|
11049
|
+
const created = await client.createFunction(resolvedAppId, buildFunctionCreatePayload(key, tomlData.function ?? {})).catch(async (err) => {
|
|
11050
|
+
// #1006 — a function the server already holds under this key
|
|
11051
|
+
// is adopted rather than re-created. The conflict may name a
|
|
11052
|
+
// WORKFLOW, a script or a webhook instead: those are the
|
|
11053
|
+
// single key namespace refusing the key, and they are not
|
|
11054
|
+
// adoptable, so `adoptByKeyOnCreate409` re-throws the original
|
|
11055
|
+
// error with its holder named.
|
|
11056
|
+
const existing = await adoptByKeyOnCreate409({
|
|
11057
|
+
err,
|
|
11058
|
+
kind: "function",
|
|
11059
|
+
key,
|
|
11060
|
+
lookup: async () => (await client.listFunctions(resolvedAppId)).items || [],
|
|
11061
|
+
matchKey: (f) => f?.functionKey === key,
|
|
11062
|
+
});
|
|
11063
|
+
return existing;
|
|
11064
|
+
});
|
|
11065
|
+
functionId = created.functionId;
|
|
11066
|
+
info(` Created function: ${key}`);
|
|
11067
|
+
}
|
|
11068
|
+
else if (headerAction !== "skip") {
|
|
11069
|
+
const updatePayload = buildFunctionUpdatePayload(tomlData.function ?? {});
|
|
11070
|
+
if (Object.keys(updatePayload).length > 0) {
|
|
11071
|
+
await client.updateFunction(resolvedAppId, functionId, updatePayload);
|
|
11072
|
+
}
|
|
11073
|
+
info(` Updated function: ${key}`);
|
|
11074
|
+
}
|
|
11075
|
+
// The version. `status` is NEVER in this body: pushing code to a
|
|
11076
|
+
// disabled function updates it and leaves it out of service
|
|
11077
|
+
// (#2803's resurrection guard), and a push to an archived one is
|
|
11078
|
+
// refused by the server with the archived-holder message — which
|
|
11079
|
+
// names the remedies that release its key.
|
|
11080
|
+
pushedVersion = await client.pushFunctionConfig(resolvedAppId, String(functionId), push);
|
|
11081
|
+
}
|
|
11082
|
+
catch (err) {
|
|
11083
|
+
applyFailures.push({
|
|
11084
|
+
type: "function",
|
|
11085
|
+
key,
|
|
11086
|
+
message: String(err?.message ?? err),
|
|
11087
|
+
});
|
|
11088
|
+
continue;
|
|
11089
|
+
}
|
|
11090
|
+
if (pushedVersion?.created === false) {
|
|
11091
|
+
info(` Function ${key}: code unchanged (no new version)`);
|
|
11092
|
+
}
|
|
11093
|
+
else {
|
|
11094
|
+
info(` Pushed function ${key} version ${pushedVersion?.configId ?? ""}`.trim());
|
|
11095
|
+
}
|
|
11096
|
+
if (syncState) {
|
|
11097
|
+
if (!syncState.entities.functions)
|
|
11098
|
+
syncState.entities.functions = {};
|
|
11099
|
+
let refreshed = liveFunction;
|
|
11100
|
+
try {
|
|
11101
|
+
refreshed = await client.getFunction(resolvedAppId, String(functionId));
|
|
11102
|
+
}
|
|
11103
|
+
catch {
|
|
11104
|
+
// Non-fatal: the baseline falls back to what the listing said.
|
|
11105
|
+
// The next push reconciles it.
|
|
11106
|
+
}
|
|
11107
|
+
syncState.entities.functions[key] = {
|
|
11108
|
+
id: String(functionId),
|
|
11109
|
+
modifiedAt: refreshed?.modifiedAt ||
|
|
11110
|
+
existingEntry?.modifiedAt ||
|
|
11111
|
+
new Date().toISOString(),
|
|
11112
|
+
contentHash: computeFileHash(filePath),
|
|
11113
|
+
semanticHash: refreshed
|
|
11114
|
+
? hashRemoteConfigForDiff(functionSpec, refreshed, pushDiffMaps, pushedVersion ?? undefined)
|
|
11115
|
+
: undefined,
|
|
11116
|
+
envelopeHash: push.envelopeHash,
|
|
11117
|
+
activeConfigId: pushedVersion?.configId,
|
|
11118
|
+
sources: push.sources.map((src) => src.path),
|
|
11119
|
+
};
|
|
11120
|
+
}
|
|
11121
|
+
}
|
|
11122
|
+
}
|
|
10579
11123
|
// Process workflows
|
|
10580
11124
|
const workflowsDir = join(configDir, "workflows");
|
|
10581
11125
|
if (existsSync(workflowsDir)) {
|
|
@@ -13786,7 +14330,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
13786
14330
|
notCompared.push({ type: label, reason: gateReason(outcome.error) });
|
|
13787
14331
|
return empty;
|
|
13788
14332
|
};
|
|
13789
|
-
const [integrationsOutcome, promptsOutcome, workflowsOutcome, emailTemplatesOutcomeDiff, webhooksOutcome, cronTriggersOutcomeDiff, blobBucketsOutcomeDiff, scriptsOutcomeDiff, varsOutcomeDiff, databaseTypeConfigsOutcomeDiff, ruleSetsOutcomeDiff, groupTypeConfigsOutcomeDiff, collectionTypeConfigsOutcomeDiff, metadataCategoryConfigsOutcomeDiff,] = await Promise.all([
|
|
14333
|
+
const [integrationsOutcome, promptsOutcome, workflowsOutcome, emailTemplatesOutcomeDiff, webhooksOutcome, cronTriggersOutcomeDiff, blobBucketsOutcomeDiff, scriptsOutcomeDiff, functionsOutcomeDiff, varsOutcomeDiff, databaseTypeConfigsOutcomeDiff, ruleSetsOutcomeDiff, groupTypeConfigsOutcomeDiff, collectionTypeConfigsOutcomeDiff, metadataCategoryConfigsOutcomeDiff,] = await Promise.all([
|
|
13790
14334
|
attemptFetch(() => fetchAll((p) => client.listIntegrations(resolvedAppId, p))),
|
|
13791
14335
|
attemptFetch(() => fetchAll((p) => client.listPrompts(resolvedAppId, p))),
|
|
13792
14336
|
attemptFetch(() => fetchAll((p) => client.listWorkflows(resolvedAppId, p))),
|
|
@@ -13795,6 +14339,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
13795
14339
|
attemptFetch(() => client.listCronTriggers(resolvedAppId)),
|
|
13796
14340
|
attemptFetch(() => client.listBlobBuckets(resolvedAppId)),
|
|
13797
14341
|
attemptFetch(() => client.listScripts(resolvedAppId)),
|
|
14342
|
+
attemptFetch(() => client.listFunctions(resolvedAppId)),
|
|
13798
14343
|
attemptFetch(() => client.listAppConfigVars(resolvedAppId)),
|
|
13799
14344
|
attemptFetch(() => client.listDatabaseTypeConfigs(resolvedAppId)),
|
|
13800
14345
|
attemptFetch(() => client.listRuleSets(resolvedAppId)),
|
|
@@ -13810,6 +14355,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
13810
14355
|
const cronTriggerItemsDiff = gate(cronTriggersOutcomeDiff, "cron-triggers", { items: [] }).items || [];
|
|
13811
14356
|
const blobBucketsDiffResult = gate(blobBucketsOutcomeDiff, "blob-buckets", { items: [] });
|
|
13812
14357
|
const scriptItemsDiff = gate(scriptsOutcomeDiff, "transforms", { items: [] }).items || [];
|
|
14358
|
+
const functionItemsDiff = gate(functionsOutcomeDiff, "functions", { items: [] }).items || [];
|
|
13813
14359
|
const remoteVarItemsDiff = gate(varsOutcomeDiff, "vars", []);
|
|
13814
14360
|
const databaseTypeConfigsDiff = gate(databaseTypeConfigsOutcomeDiff, "database-type-configs", []);
|
|
13815
14361
|
const ruleSetsDiff = gate(ruleSetsOutcomeDiff, "rule-sets", []);
|
|
@@ -14114,6 +14660,25 @@ export function registerConfigSyncCommands(sync) {
|
|
|
14114
14660
|
localScripts.set(name, readFileSync(join(transformsDirPath, file), "utf-8"));
|
|
14115
14661
|
}
|
|
14116
14662
|
}
|
|
14663
|
+
// Server functions — #3179. Two axes, exactly as push has: the
|
|
14664
|
+
// header/version fields through the shared spec, and the CODE through
|
|
14665
|
+
// `envelopeHash`. The code axis is why a function's diff builds the
|
|
14666
|
+
// bundle: a comment-only edit to a source file changes nothing the
|
|
14667
|
+
// table can see and is still a change push will apply.
|
|
14668
|
+
const remoteFunctions = new Map(functionItemsDiff
|
|
14669
|
+
.filter((f) => f?.functionKey && String(f.status ?? "active") !== "archived")
|
|
14670
|
+
.map((f) => [f.functionKey, f]));
|
|
14671
|
+
const localFunctionsParsed = new Map();
|
|
14672
|
+
const functionsDirPath = join(configDir, "functions");
|
|
14673
|
+
if (functionsOutcomeDiff.ok && existsSync(functionsDirPath)) {
|
|
14674
|
+
for (const file of readdirSync(functionsDirPath).filter((f) => f.endsWith(".toml"))) {
|
|
14675
|
+
const filePath = join(functionsDirPath, file);
|
|
14676
|
+
const tomlData = parseTomlFile(filePath);
|
|
14677
|
+
const key = tomlData.function?.key || basename(file, ".toml");
|
|
14678
|
+
withDerivedIdentity(tomlData, "function", { key });
|
|
14679
|
+
localFunctionsParsed.set(key, { tomlData, filePath });
|
|
14680
|
+
}
|
|
14681
|
+
}
|
|
14117
14682
|
// Config vars — issue #1423. The remote vars were fetched through the
|
|
14118
14683
|
// ok/error gate above (`remoteVarItemsDiff`); the local `vars.toml` is
|
|
14119
14684
|
// read only when that fetch succeeded.
|
|
@@ -14377,6 +14942,89 @@ export function registerConfigSyncCommands(sync) {
|
|
|
14377
14942
|
differences.push({ type: "prompt", key, status: remoteOnlyStatus("prompts", key) });
|
|
14378
14943
|
}
|
|
14379
14944
|
}
|
|
14945
|
+
// Server functions — #3179.
|
|
14946
|
+
const functionSpecDiff = configDiffSpec("function");
|
|
14947
|
+
const functionDiffMaps = {
|
|
14948
|
+
ruleSetIdToName: new Map(),
|
|
14949
|
+
ruleSetNameToId: new Map(),
|
|
14950
|
+
};
|
|
14951
|
+
for (const [key, local] of localFunctionsParsed) {
|
|
14952
|
+
const remote = remoteFunctions.get(key);
|
|
14953
|
+
if (!remote) {
|
|
14954
|
+
differences.push({
|
|
14955
|
+
type: "function",
|
|
14956
|
+
key,
|
|
14957
|
+
status: localOnlyStatus("functions", key),
|
|
14958
|
+
});
|
|
14959
|
+
continue;
|
|
14960
|
+
}
|
|
14961
|
+
let status = "exists";
|
|
14962
|
+
let hint;
|
|
14963
|
+
// The active version carries `entry`, `durable` and `limits`, and its
|
|
14964
|
+
// `envelopeHash` is the code axis.
|
|
14965
|
+
const versionOutcome = await attemptFetch(async () => {
|
|
14966
|
+
if (!remote.activeConfigId)
|
|
14967
|
+
return null;
|
|
14968
|
+
const { items } = await client.listFunctionConfigs(resolvedAppId, remote.functionId);
|
|
14969
|
+
return ((items || []).find((c) => String(c.configId) === String(remote.activeConfigId)) ?? null);
|
|
14970
|
+
});
|
|
14971
|
+
if (!versionOutcome.ok) {
|
|
14972
|
+
hint = "content not compared (fetch failed)";
|
|
14973
|
+
}
|
|
14974
|
+
else {
|
|
14975
|
+
const active = versionOutcome.value;
|
|
14976
|
+
const verdict = compareLocalToRemote(functionSpecDiff, local.tomlData, remote, functionDiffMaps, active ?? undefined);
|
|
14977
|
+
status = verdict.status;
|
|
14978
|
+
hint = verdict.hint;
|
|
14979
|
+
if (status === "exists") {
|
|
14980
|
+
// The header agrees; the code is the other half. Building here is
|
|
14981
|
+
// what makes "config diff is clean immediately after pull" a
|
|
14982
|
+
// statement about the SOURCES and not just the TOML.
|
|
14983
|
+
try {
|
|
14984
|
+
const built = await buildFunctionConfigPush({
|
|
14985
|
+
configDir,
|
|
14986
|
+
functionKey: key,
|
|
14987
|
+
filePath: local.filePath,
|
|
14988
|
+
tomlData: local.tomlData,
|
|
14989
|
+
});
|
|
14990
|
+
if (!active ||
|
|
14991
|
+
String(active.envelopeHash ?? "") !== built.envelopeHash) {
|
|
14992
|
+
status = "modified";
|
|
14993
|
+
hint =
|
|
14994
|
+
"the built bundle or a source file differs — `config push` " +
|
|
14995
|
+
"creates a new immutable version";
|
|
14996
|
+
}
|
|
14997
|
+
}
|
|
14998
|
+
catch (err) {
|
|
14999
|
+
if (err instanceof FunctionBuildError) {
|
|
15000
|
+
// #2880 — the file is not pushable, so the row says so
|
|
15001
|
+
// rather than reading as Synced. This is the state a
|
|
15002
|
+
// function created through the API and never pushed lands
|
|
15003
|
+
// in: pull can only serialize the header, so the file has
|
|
15004
|
+
// no `entry` and the very next push refuses it. A Synced row
|
|
15005
|
+
// there tells the operator there is nothing to do about a
|
|
15006
|
+
// file nothing can apply.
|
|
15007
|
+
status = VALIDATION_ERROR;
|
|
15008
|
+
hint = err.message.split("\n")[0];
|
|
15009
|
+
}
|
|
15010
|
+
else {
|
|
15011
|
+
hint = "content not compared (comparison failed)";
|
|
15012
|
+
}
|
|
15013
|
+
}
|
|
15014
|
+
}
|
|
15015
|
+
}
|
|
15016
|
+
hint = outOfServiceHint(remote, hint);
|
|
15017
|
+
differences.push({ type: "function", key, status, hint });
|
|
15018
|
+
}
|
|
15019
|
+
for (const key of remoteFunctions.keys()) {
|
|
15020
|
+
if (!localFunctionsParsed.has(key)) {
|
|
15021
|
+
differences.push({
|
|
15022
|
+
type: "function",
|
|
15023
|
+
key,
|
|
15024
|
+
status: remoteOnlyStatus("functions", key),
|
|
15025
|
+
});
|
|
15026
|
+
}
|
|
15027
|
+
}
|
|
14380
15028
|
// Workflows — #1175: content-aware comparison.
|
|
14381
15029
|
//
|
|
14382
15030
|
// For workflows present on BOTH sides we no longer stop at key
|