@vxil/cli 0.11.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/config.d.ts CHANGED
@@ -40,7 +40,22 @@ export interface FieldDef {
40
40
  * `orgs` role alphabet). The gate
41
41
  * never affects WRITES. Carried by BOTH push paths and alterable in place. */
42
42
  readRoles?: string[];
43
+ /** EQUALITY INDEX for an UNSLOTTED field (cms.md §3 "Indexed equality"):
44
+ * `=` / `$eq` / `$in` filters on it are index-served instead of a bounded
45
+ * scan — results are identical either way. At most 4 per collection (the
46
+ * equality-key budget, separate from the 8 index slots); not together with
47
+ * `indexSlot` (a slot already indexes equality) and not on a computed field
48
+ * (both checked here at define time and again by the server). Carried by
49
+ * BOTH push paths and alterable in place; on a collection over 2,000 live
50
+ * items `vxil push` also runs the re-index that arms it. */
51
+ indexed?: boolean;
43
52
  }
53
+ /** The per-collection budget of `indexed: true` fields (cms/0158: e1…e4). */
54
+ export declare const CMS_INDEXED_FIELD_BUDGET = 4;
55
+ /** Define-time checks of a collection's `indexed` fields — the same refusals
56
+ * the server makes (422), so a config fails at `defineConfig` / `collection()`
57
+ * instead of mid-push. Throws on the first violation. */
58
+ export declare function assertIndexedFields(name: string, def: CollectionDef): void;
44
59
  /** One config-declared per-record ACTION button (cms.md §17): exactly ONE
45
60
  * human-initiated step — pressing it invokes the deployed tenant function `fn`
46
61
  * with `{ collection, item_id, action, actor, item }` and returns its result.
package/dist/config.js CHANGED
@@ -1,4 +1,21 @@
1
1
  // ../config/src/index.ts
2
+ var CMS_INDEXED_FIELD_BUDGET = 4;
3
+ function assertIndexedFields(name, def) {
4
+ let n = 0;
5
+ for (const [fname, f] of Object.entries(def.fields ?? {})) {
6
+ if (f.indexed !== true) continue;
7
+ n += 1;
8
+ if (f.indexSlot) {
9
+ throw new Error(`vxil.config: cms.${name}.${fname} sets both indexed and indexSlot '${f.indexSlot}' \u2014 a slot already indexes equality; use one`);
10
+ }
11
+ if (f.computed) {
12
+ throw new Error(`vxil.config: cms.${name}.${fname} is computed \u2014 a computed field cannot be indexed (bind it to an indexSlot instead)`);
13
+ }
14
+ }
15
+ if (n > CMS_INDEXED_FIELD_BUDGET) {
16
+ throw new Error(`vxil.config: cms.${name} has ${n} indexed fields \u2014 at most ${CMS_INDEXED_FIELD_BUDGET} per collection`);
17
+ }
18
+ }
2
19
  var RELEASED_API_VERSIONS = ["v1"];
3
20
  function defineConfig(c) {
4
21
  if (c.apiVersion !== void 0 && !RELEASED_API_VERSIONS.includes(c.apiVersion)) {
@@ -6,16 +23,20 @@ function defineConfig(c) {
6
23
  `vxil.config: apiVersion '${String(c.apiVersion)}' is not a released API major (released: ${RELEASED_API_VERSIONS.map((v) => `'${v}'`).join(", ")}). A new major ships as a new path with its own deprecation window \u2014 see https://vxil.com/docs/guide/12-going-to-production.`
7
24
  );
8
25
  }
26
+ for (const [name, def] of Object.entries(c.cms?.collections ?? {})) assertIndexedFields(name, def);
9
27
  return c;
10
28
  }
11
29
  function field(type, opts = {}) {
12
30
  return { type, ...opts };
13
31
  }
14
32
  function collection(def) {
33
+ assertIndexedFields(def.singular ?? "collection", def);
15
34
  return def;
16
35
  }
17
36
  export {
37
+ CMS_INDEXED_FIELD_BUDGET,
18
38
  RELEASED_API_VERSIONS,
39
+ assertIndexedFields,
19
40
  collection,
20
41
  defineConfig,
21
42
  field
package/dist/vxil.js CHANGED
@@ -4461,9 +4461,9 @@ function lowerAllTriggerBindings(def, name) {
4461
4461
  return bindings;
4462
4462
  }
4463
4463
  var CONFIG_FILENAMES = ["vxil.config.ts", "vxil.config.mjs", "vxil.config.js"];
4464
- var VXIL_CONFIG_PKG_VERSION = "0.7.0";
4465
- var VXIL_SDK_PKG_VERSION = "0.11.1";
4466
- var VXIL_CLI_PKG_VERSION = "0.11.1";
4464
+ var VXIL_CONFIG_PKG_VERSION = "0.8.0";
4465
+ var VXIL_SDK_PKG_VERSION = "0.13.0";
4466
+ var VXIL_CLI_PKG_VERSION = "0.13.0";
4467
4467
  function ensureScaffoldPackageJson(cwd, opts = {}) {
4468
4468
  const file = resolve(cwd, "package.json");
4469
4469
  const wanted = {
@@ -4748,6 +4748,7 @@ async function serverApply({ api, bundle, resume, allowDestructive, onPlan }) {
4748
4748
  `apply execute (resume with: vxil push --server --resume ${plan.apply_id})`
4749
4749
  );
4750
4750
  }
4751
+ var PUSH_READ_CONCURRENCY = 5;
4751
4752
  async function planOrPush({ api, features, apply, allowDestructive }) {
4752
4753
  const results = [];
4753
4754
  const destructiveQs = allowDestructive ? "&allow_destructive=true" : "";
@@ -4755,8 +4756,20 @@ async function planOrPush({ api, features, apply, allowDestructive }) {
4755
4756
  const v = r.body.data?.api_state;
4756
4757
  return v && typeof v === "object" && typeof v.ok === "boolean" ? v : void 0;
4757
4758
  };
4758
- for (const [feature, manifest] of Object.entries(features)) {
4759
- const dry = await api("PUT", `/v1/config/${feature}?dry_run=true${destructiveQs}`, manifest);
4759
+ const entries = Object.entries(features);
4760
+ const reads = await mapPool(entries, PUSH_READ_CONCURRENCY, async ([feature, manifest]) => {
4761
+ try {
4762
+ const dry = await api("PUT", `/v1/config/${feature}?dry_run=true${destructiveQs}`, manifest);
4763
+ if (dry.status !== 200) return { dry };
4764
+ return { dry, cur: await api("GET", `/v1/config/${feature}`) };
4765
+ } catch (err) {
4766
+ return { err };
4767
+ }
4768
+ });
4769
+ for (const [i, [feature, manifest]] of entries.entries()) {
4770
+ const read = reads[i];
4771
+ if ("err" in read) throw read.err;
4772
+ const dry = read.dry;
4760
4773
  if (dry.status !== 200) {
4761
4774
  const e = dry.body.error ?? {};
4762
4775
  throw new Error(
@@ -4765,7 +4778,7 @@ async function planOrPush({ api, features, apply, allowDestructive }) {
4765
4778
  }
4766
4779
  const effective = dry.body.data?.manifest;
4767
4780
  const apiStatePlan = apiStateOf(dry);
4768
- const cur = await api("GET", `/v1/config/${feature}`);
4781
+ const cur = read.cur;
4769
4782
  const unavailable = readUnavailable(`GET /v1/config/${feature}`, cur);
4770
4783
  if (unavailable) {
4771
4784
  if (apply) {
@@ -7706,6 +7719,58 @@ function hookReconcileOf(data2) {
7706
7719
  function hookReconcileRemedy(fnName) {
7707
7720
  return fnName ? `re-run the reconcile with \`vxil functions deploy ${fnName} --force\` or \`vxil push --server\` (a plain re-push sends no deploy for an unchanged function, so it would not retry it)` : "re-run the reconcile with `vxil push --server` (a plain re-push sends no deploy, so it would not retry it)";
7708
7721
  }
7722
+ var WIRING_WARNING = /trigger subscription|cron schedule/i;
7723
+ function triggerWiringOf(data2) {
7724
+ if (!data2) return null;
7725
+ const counts = (v) => {
7726
+ if (!v || typeof v !== "object" || Array.isArray(v)) return void 0;
7727
+ const o = v;
7728
+ const n = (x) => typeof x === "number" && Number.isFinite(x) ? x : 0;
7729
+ const opt = (x) => typeof x === "number" && Number.isFinite(x) ? x : void 0;
7730
+ const updated = opt(o.updated);
7731
+ const unchanged = opt(o.unchanged);
7732
+ return {
7733
+ created: n(o.created),
7734
+ ...updated !== void 0 ? { updated } : {},
7735
+ ...unchanged !== void 0 ? { unchanged } : {},
7736
+ deleted: n(o.deleted),
7737
+ failed: n(o.failed)
7738
+ };
7739
+ };
7740
+ const cms = counts(data2.cms_hook_subscriptions);
7741
+ const webhook = counts(data2.webhook_subscriptions);
7742
+ const cron = counts(data2.cron_schedules);
7743
+ const total = counts(data2.hook_reconcile);
7744
+ if (!total && !cms && !webhook && !cron) return null;
7745
+ const warnings = Array.isArray(data2.warnings) ? data2.warnings.filter((w) => typeof w === "string" && WIRING_WARNING.test(w)) : [];
7746
+ return {
7747
+ total: total ?? { created: 0, deleted: 0, failed: (cms?.failed ?? 0) + (webhook?.failed ?? 0) + (cron?.failed ?? 0) },
7748
+ ...cms ? { cms_hook_subscriptions: cms } : {},
7749
+ ...webhook ? { webhook_subscriptions: webhook } : {},
7750
+ ...cron ? { cron_schedules: cron } : {},
7751
+ warnings
7752
+ };
7753
+ }
7754
+ function formatTriggerWiring(r) {
7755
+ const line = (c) => [
7756
+ `${c.created} created`,
7757
+ ...c.updated !== void 0 ? [`${c.updated} updated`] : [],
7758
+ ...c.unchanged !== void 0 ? [`${c.unchanged} unchanged`] : [],
7759
+ `${c.deleted} removed`,
7760
+ `${c.failed} failed`
7761
+ ].join(" \xB7 ");
7762
+ const lanes = [
7763
+ ["hook subscriptions (cms/auth)", r.cms_hook_subscriptions],
7764
+ ["webhook-trigger subscriptions", r.webhook_subscriptions],
7765
+ ["cron schedules", r.cron_schedules]
7766
+ ];
7767
+ const out = [r.total.failed > 0 ? `trigger wiring (reconciled by the server) \u2014 ${r.total.failed} FAILED:` : "trigger wiring (reconciled by the server):"];
7768
+ const shown = lanes.filter(([, c]) => c);
7769
+ if (shown.length) for (const [label, c] of shown) out.push(` ${label.padEnd(30)} ${line(c)}`);
7770
+ else out.push(` ${"all triggers".padEnd(30)} ${line(r.total)}`);
7771
+ for (const w of r.warnings) out.push(` ! ${w}`);
7772
+ return out;
7773
+ }
7709
7774
  function sourceSha12(source, runtime) {
7710
7775
  const suffix = runtime ? functionsRuntimeHashSuffix(runtime) : "";
7711
7776
  return createHash("sha256").update(source + suffix, "utf8").digest("hex").slice(0, 12);
@@ -8085,6 +8150,14 @@ function formatLogLines(lines) {
8085
8150
  }
8086
8151
 
8087
8152
  // src/cms.ts
8153
+ function indexReadyOf(rf) {
8154
+ return rf.index_ready === true;
8155
+ }
8156
+ var EQ_INDEX_INLINE_MAX_ROWS = 2e3;
8157
+ function indexArmNote(rows) {
8158
+ if (rows === null) return "arms inline or through the re-index push runs (live row count unavailable)";
8159
+ return rows <= EQ_INDEX_INLINE_MAX_ROWS ? `arms inline (${rows} rows)` : `needs reindex (${rows} rows; push runs it)`;
8160
+ }
8088
8161
  function normalizeActions(raw) {
8089
8162
  if (!Array.isArray(raw)) return [];
8090
8163
  return raw.filter((a) => !!a && typeof a === "object").map((a) => ({ key: String(a.key), label: String(a.label), fn: String(a.fn) }));
@@ -8104,7 +8177,10 @@ var FIELD_ATTRS = [
8104
8177
  // (the server stores NULL for "ungated" and normalizes `[]` to NULL), so an
8105
8178
  // omitted attribute diffs clean; CLEARING it WIDENS access and therefore rides
8106
8179
  // the same --allow-destructive gate as clearing unique/on_delete/owner_field.
8107
- { config: "readRoles", wire: "read_roles", remote: "read_roles", blank: null, alter: "tighten-only" }
8180
+ { config: "readRoles", wire: "read_roles", remote: "read_roles", blank: null, alter: "tighten-only" },
8181
+ // PERF-26b: the equality index of an unslotted field (≤4 per collection).
8182
+ // Present-key on the server; arming on > 2,000 live rows runs the re-index.
8183
+ { config: "indexed", wire: "indexed", remote: "indexed", blank: false, alter: "index" }
8108
8184
  ];
8109
8185
  function fieldToInput(name, def) {
8110
8186
  const out = { field: name, type: def.type };
@@ -8127,7 +8203,7 @@ function remoteFieldToConfig(rf) {
8127
8203
  function fieldAttrDelta(def, rf) {
8128
8204
  const d = def;
8129
8205
  const r = rf;
8130
- const out = { flags: [], clearing: [], inPlace: [], reindex: [], retarget: [], drift: [] };
8206
+ const out = { flags: [], clearing: [], inPlace: [], reindex: [], retarget: [], index: [], drift: [] };
8131
8207
  for (const a of FIELD_ATTRS) {
8132
8208
  const wantBlank = stableStringify(a.blank);
8133
8209
  const want = stableStringify(d[a.config] ?? a.blank);
@@ -8144,6 +8220,7 @@ function fieldAttrDelta(def, rf) {
8144
8220
  }
8145
8221
  if (a.alter === "reindex") out.reindex.push(line);
8146
8222
  else if (a.alter === "destructive") out.retarget.push(line);
8223
+ else if (a.alter === "index") out.index.push(line);
8147
8224
  else out.inPlace.push(line);
8148
8225
  }
8149
8226
  return out;
@@ -8189,6 +8266,7 @@ async function reindexCollection({ api, collection, field, onProgress }) {
8189
8266
  let updated = 0;
8190
8267
  let pages = 0;
8191
8268
  let skipped = 0;
8269
+ let indexCertify;
8192
8270
  const page = async () => {
8193
8271
  const res = await api("POST", `/v1/cms/collections/${encodeURIComponent(collection)}/reindex`, {
8194
8272
  ...field ? { field } : {},
@@ -8202,6 +8280,7 @@ async function reindexCollection({ api, collection, field, onProgress }) {
8202
8280
  scanned += d.scanned ?? 0;
8203
8281
  updated += d.updated ?? 0;
8204
8282
  pages++;
8283
+ if (d.index_certify) indexCertify = d.index_certify;
8205
8284
  return d;
8206
8285
  };
8207
8286
  for (; ; ) {
@@ -8218,7 +8297,7 @@ async function reindexCollection({ api, collection, field, onProgress }) {
8218
8297
  }
8219
8298
  cursor = d.next_cursor;
8220
8299
  }
8221
- return { scanned, updated, pages, skipped };
8300
+ return { scanned, updated, pages, skipped, ...indexCertify ? { indexCertify } : {} };
8222
8301
  }
8223
8302
  async function planCmsSchema({ api, collections, apply, allowDestructive = false, onProgress }) {
8224
8303
  const read = await readRemote2(api);
@@ -8247,11 +8326,17 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
8247
8326
  const collChanges = [];
8248
8327
  const post = [];
8249
8328
  const slotMoved = [];
8329
+ const indexArming = [];
8330
+ const indexVacating = [];
8331
+ let rowsMemo;
8332
+ const rows = () => rowsMemo ??= liveRowCount(api, name);
8250
8333
  const remoteByName = new Map(rc.fields.map((f) => [f.field, f]));
8251
8334
  for (const [fn, fd] of declaredFields) {
8252
8335
  const existing = remoteByName.get(fn);
8253
8336
  if (!existing) {
8254
- collChanges.push({ collection: name, kind: "add-field", field: fn, detail: `${name}.${fn} (${fd.type})` });
8337
+ const arm = fd.indexed === true ? `; indexed: ${indexArmNote(await rows())}` : "";
8338
+ if (fd.indexed === true) indexArming.push(fn);
8339
+ collChanges.push({ collection: name, kind: "add-field", field: fn, detail: `${name}.${fn} (${fd.type})${arm}` });
8255
8340
  } else if (existing.type !== fd.type) {
8256
8341
  if (allowDestructive && (fd.indexSlot ?? null) !== (existing.index_slot ?? null)) {
8257
8342
  if (existing.index_slot) {
@@ -8274,10 +8359,14 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
8274
8359
  detail: `${name}.${fn} type ${existing.type} \u2192 ${fd.type} (destructive alter)`
8275
8360
  });
8276
8361
  } else {
8277
- const { flags, clearing, inPlace, reindex, retarget, drift } = fieldAttrDelta(fd, existing);
8362
+ const { flags, clearing, inPlace, reindex, retarget, index, drift } = fieldAttrDelta(fd, existing);
8363
+ const armsIndex = fd.indexed === true && (index.length > 0 || !indexReadyOf(existing));
8364
+ const indexNote = armsIndex ? ` \u2014 ${fn} indexed: ${indexArmNote(await rows())}` : "";
8365
+ if (armsIndex) indexArming.push(fn);
8366
+ if (index.length > 0 && fd.indexed !== true && existing.indexed === true) indexVacating.push(fn);
8278
8367
  const destructiveAttrs = [...clearing, ...reindex, ...retarget];
8279
8368
  if (destructiveAttrs.length) {
8280
- const all = [...destructiveAttrs, ...flags, ...inPlace];
8369
+ const all = [...destructiveAttrs, ...flags, ...inPlace, ...index];
8281
8370
  if (allowDestructive) {
8282
8371
  if (reindex.length && existing.index_slot) {
8283
8372
  pre.push({
@@ -8294,7 +8383,7 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
8294
8383
  kind: "destructive",
8295
8384
  op: "alter-field",
8296
8385
  field: fn,
8297
- detail: `${name}.${fn} ${all.join(", ")}` + (clearing.length ? " \u2014 clearing a live invariant (unique claims / on_delete cascade / read_roles gate)" : "") + (reindex.length ? " \u2014 an index_slot move: stored rows keep the OLD projection until the re-index runs" : "") + (retarget.length ? " \u2014 a relation retarget: every stored id now names a different collection" : "") + " (full-config reconciliation)"
8386
+ detail: `${name}.${fn} ${all.join(", ")}` + (clearing.length ? " \u2014 clearing a live invariant (unique claims / on_delete cascade / read_roles gate)" : "") + (reindex.length ? " \u2014 an index_slot move: stored rows keep the OLD projection until the re-index runs" : "") + (retarget.length ? " \u2014 a relation retarget: every stored id now names a different collection" : "") + indexNote + " (full-config reconciliation)"
8298
8387
  });
8299
8388
  if (reindex.length) slotMoved.push(fn);
8300
8389
  } else {
@@ -8305,12 +8394,12 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
8305
8394
  detail: `${name}.${fn} ${all.join(", ")}: this is DESTRUCTIVE-SHAPED \u2014 ` + (clearing.length ? "it CLEARS a live invariant (clearing unique DROPS the uniqueness claims \u2014 duplicates then accepted, unrecoverable; clearing on_delete stops cascades; clearing read_roles WIDENS the field to every end-user); " : "") + (reindex.length ? "an index_slot move leaves stored rows on the OLD projection until a re-index, so queries on the field return the WRONG rows; " : "") + (retarget.length ? "a relation retarget repoints every stored id at another collection; " : "") + "it is NOT applied (nor is any co-declared in-place change on this field). Re-run with --allow-destructive to apply."
8306
8395
  });
8307
8396
  }
8308
- } else if (flags.length || inPlace.length) {
8397
+ } else if (flags.length || inPlace.length || index.length) {
8309
8398
  collChanges.push({
8310
8399
  collection: name,
8311
8400
  kind: "alter-flags",
8312
8401
  field: fn,
8313
- detail: `${name}.${fn} ${[...flags, ...inPlace].join(", ")} (non-destructive in-place alter)`
8402
+ detail: `${name}.${fn} ${[...flags, ...inPlace, ...index].join(", ")} (non-destructive in-place alter)${indexNote}`
8314
8403
  });
8315
8404
  }
8316
8405
  if (drift.length) {
@@ -8374,12 +8463,25 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
8374
8463
  });
8375
8464
  }
8376
8465
  }
8377
- if (slotMoved.length) {
8378
- const n = await liveRowCount(api, name);
8466
+ let armReindex = false;
8467
+ let vacateReindex = false;
8468
+ if (indexArming.length > 0 || indexVacating.length > 0) {
8469
+ const n = await rows();
8470
+ const large = n === null || n > EQ_INDEX_INLINE_MAX_ROWS;
8471
+ armReindex = large && indexArming.length > 0;
8472
+ vacateReindex = large && indexVacating.length > 0;
8473
+ }
8474
+ if (slotMoved.length || armReindex || vacateReindex) {
8475
+ const n = await rows();
8476
+ const why = [
8477
+ ...slotMoved.length ? [`the index_slot move on ${slotMoved.join(", ")}`] : [],
8478
+ ...armReindex ? [`arming the equality index on ${indexArming.join(", ")}`] : [],
8479
+ ...vacateReindex ? [`clearing the equality index of ${indexVacating.join(", ")}`] : []
8480
+ ];
8379
8481
  post.push({
8380
8482
  collection: name,
8381
8483
  kind: "reindex",
8382
- detail: `${name} re-index ${n === null ? "live rows" : `${n} live rows`} after the index_slot move on ${slotMoved.join(", ")}`
8484
+ detail: `${name} re-index ${n === null ? "live rows" : `${n} live rows`} after ${why.join(" and ")}`
8383
8485
  });
8384
8486
  }
8385
8487
  changes.push(...pre, ...collChanges, ...post);
@@ -8469,6 +8571,8 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
8469
8571
  if (done.skipped > 0) {
8470
8572
  onProgress?.(` \u26A0 ${done.skipped} row(s) of ${c.collection} were written while the re-index ran and were NOT re-projected \u2014 re-run \`vxil cms reindex ` + c.collection + "` when writes are quiet");
8471
8573
  }
8574
+ const certifyWarning = indexCertifyWarning(c.collection, done.indexCertify);
8575
+ if (certifyWarning) onProgress?.(certifyWarning);
8472
8576
  applied++;
8473
8577
  } else if (c.kind === "destructive") {
8474
8578
  applied += await applyDestructive(api, collections, c);
@@ -8543,6 +8647,10 @@ function formatCollectionDrop(collection, plan, declaredLocally) {
8543
8647
  }
8544
8648
  return lines.join("\n");
8545
8649
  }
8650
+ function indexCertifyWarning(collection, certify) {
8651
+ if (certify !== "timeout" && certify !== "pending") return null;
8652
+ return ` \u26A0 the index on ${collection} is NOT armed yet (` + (certify === "timeout" ? "its certification ran out of time" : "rows changed during the pass") + ") \u2014 filters stay correct but unaccelerated; re-run `vxil cms reindex " + collection + "`";
8653
+ }
8546
8654
 
8547
8655
  // src/apiCmd.ts
8548
8656
  var API_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
@@ -11668,7 +11776,7 @@ var TOOLS = [
11668
11776
  {
11669
11777
  name: "cms_list_collections",
11670
11778
  feature: "cms",
11671
- description: "List the tenant's content model: collections with their typed field definitions (the schema items are validated against). Each field row carries its attributes \u2014 required, validation, index_slot, relation_to, computed/compute, is_unique, on_delete, and read_roles (the end-user org roles allowed to READ that field; null = ungated). A field with read_roles is omitted from end-user reads without a matching role and is never served on the public lane, so a missing key in an item's data may be a read gate rather than an unset value.",
11779
+ description: "List the tenant's content model: collections with their typed field definitions (the schema items are validated against). Each field row carries its attributes \u2014 required, validation, index_slot, relation_to, computed/compute, is_unique, on_delete, read_roles (the end-user org roles allowed to READ that field; null = ungated), and on an equality-indexed field indexed: true + index_ready (equality filters on it are index-served once ready; until then, and on unindexed unslotted fields, they are a bounded scan \u2014 same results). A field with read_roles is omitted from end-user reads without a matching role and is never served on the public lane, so a missing key in an item's data may be a read gate rather than an unset value.",
11672
11780
  inputSchema: { type: "object", properties: {} },
11673
11781
  method: "GET",
11674
11782
  path: "/v1/cms/collections"
@@ -11692,7 +11800,7 @@ var TOOLS = [
11692
11800
  {
11693
11801
  name: "cms_query_items",
11694
11802
  feature: "cms",
11695
- description: 'Query content items with the bounded DSL. filter is a JSON object (\u22648 terms): {"price":{"$gte":10},"status":"published"}; ops $eq $ne $gt $gte $lt $lte $in $contains $startsWith (substring/prefix, LIKE-escaped) $arrayContains/$anyOf (array fields: all-of / any-of). sort: "-field" (slot-indexed fields + created_at/updated_at/published_at). Paging: pass the returned next_cursor back as cursor WITH THE SAME filter and sort (works for the default order and for any slot/timestamp sort; not for a dotted join sort). $expand inlines relation/file fields. count=true returns {count} instead of a page (filter only \u2014 no sort/limit/cursor/$expand).',
11803
+ description: 'Query content items with the bounded DSL. filter is a JSON object (\u22648 terms): {"price":{"$gte":10},"status":"published"}; ops $eq $ne $gt $gte $lt $lte $in $contains $startsWith (substring/prefix, LIKE-escaped) $arrayContains/$anyOf (array fields: all-of / any-of). Equality ($eq / bare value / $in) is index-served on slot-indexed and `indexed` fields; on other fields it is a bounded scan (same results). sort: "-field" (slot-indexed fields + created_at/updated_at/published_at). Paging: pass the returned next_cursor back as cursor WITH THE SAME filter and sort (works for the default order and for any slot/timestamp sort; not for a dotted join sort). $expand inlines relation/file fields. count=true returns {count} instead of a page (filter only \u2014 no sort/limit/cursor/$expand).',
11696
11804
  inputSchema: {
11697
11805
  type: "object",
11698
11806
  properties: {
@@ -12302,7 +12410,7 @@ var TOOLS = [
12302
12410
  {
12303
12411
  name: "search_query",
12304
12412
  feature: "vector-search",
12305
- description: "Retrieve the top-k most relevant chunks from a vector-search collection. mode 'hybrid' (default) fuses semantic + keyword via RRF; 'vector' is pure semantic; 'keyword' is lexical. filter is a JSON object of equality constraints over chunk metadata. Returns scored results with document/chunk attribution.",
12413
+ description: "Retrieve the top-k most relevant chunks from a vector-search collection. mode 'hybrid' (default) fuses semantic + keyword via RRF; 'vector' is pure semantic; 'keyword' is lexical. filter is a JSON object of equality constraints over chunk metadata. Returns ranked results with document/chunk attribution: score is the fused rank value (orders the hits, not a similarity); similarity is the cosine similarity to the query (-1..1, null in keyword mode) \u2014 threshold on similarity.",
12306
12414
  inputSchema: {
12307
12415
  type: "object",
12308
12416
  properties: {
@@ -12496,7 +12604,7 @@ var TOOLS = [
12496
12604
  filter: { type: "object", additionalProperties: true, description: "Metadata equality filter applied to retrieval." },
12497
12605
  rerank: { type: "boolean", description: "Forward rerank:true to vector-search's BYO rerank pass (inert until configured there)." },
12498
12606
  boosts: { type: "object", additionalProperties: true, description: "Per-metadata-field boost specs: { field: { kind:'recency', halfLifeDays?, weight? } | { kind:'value', weights:{\u2026} } }." },
12499
- min_score: { type: "number", description: "Drop hits below this EFFECTIVE (post-boost) score." }
12607
+ min_score: { type: "number", description: "Drop hits below this EFFECTIVE (post-boost) score. score is a rank value (about 0.016-0.033 at the top), so a floor above ~0.033 drops every hit; judge relevance by each hit's similarity instead." }
12500
12608
  },
12501
12609
  required: ["query"]
12502
12610
  },
@@ -13933,7 +14041,7 @@ var VERB_USAGE = {
13933
14041
  mcp: "usage: vxil mcp install [--client cursor|claude|vscode] [--project] [--print] [--key <api_key>] [--scopes a,b] [--name <server>]",
13934
14042
  plan: "usage: vxil plan [--dev|--target <name>] [--explain] [--skip-functions] \u2014 what `vxil push` would change: cms schema, feature config, declared API state, functions (a deployed function the config no longer declares shows as `-`); writes nothing",
13935
14043
  diff: "usage: vxil diff [--against <target>|--exit-code] [--explain] [--ignore <k,\u2026>] [--strict-secrets] [--strict-api-state] [--api-state] [--json] \u2014 the CI gate: exit 0 no drift \xB7 1 drift \xB7 2 error",
13936
- push: "usage: vxil push [--dev|--target <name>|--prod] [--allow-destructive] [--strict] [--server [--resume <apply_id>]] [--no-gen] [--skip-functions] [--yes] \u2014 apply the config; --allow-destructive also deletes deployed functions and stored AI templates the config no longer declares; --strict exits 1 when anything declared was refused, deferred or left unwired",
14044
+ push: "usage: vxil push [--dev|--target <name>|--prod] [--allow-destructive] [--strict] [--server [--resume <apply_id>]] [--no-gen] [--skip-functions] [--yes] \u2014 apply the config; --allow-destructive also deletes deployed functions and stored AI templates the config no longer declares; --strict exits 1 when anything declared was refused, deferred or left unwired; --server prints the trigger wiring the server reconciled (--json: one result document on stdout)",
13937
14045
  gen: "usage: vxil gen [--check] [--offline] [--out <f>] [--no-mcp] [--mcp-out <f>] \u2014 generate vxil.types.ts (+ vxil.mcp.json); --offline keeps an existing API_VERSIONS block; --check compares instead of writing",
13938
14046
  types: "usage: vxil types \u2014 alias of `vxil gen` (run `vxil gen --help`)",
13939
14047
  doctor: "usage: vxil doctor [--dev|--target <name>] \u2014 preflight: the binding's tenant vs the key's tenant, secrets referenced-vs-stored, per-function hashes, keys (read-only)",
@@ -14022,6 +14130,32 @@ function pushExitCode(p, strict) {
14022
14130
  if (p.strict.length) lines.push(`${p.strict.length} warning(s) \u2014 \`vxil push --strict\` turns them into exit 1 for CI`);
14023
14131
  return { code: 0, lines };
14024
14132
  }
14133
+ function serverApplyJson(input) {
14134
+ const { view, triggerWiring, problems, exitCode, error } = input;
14135
+ return {
14136
+ ok: exitCode === 0,
14137
+ exit_code: exitCode,
14138
+ apply_id: view.apply_id,
14139
+ status: view.status,
14140
+ steps: view.steps,
14141
+ ...view.failed_step ? { failed_step: view.failed_step } : {},
14142
+ trigger_wiring: triggerWiring ?? null,
14143
+ problems: { hard: problems.hard, strict: problems.strict },
14144
+ ...error ? { error } : {}
14145
+ };
14146
+ }
14147
+ function serverApplyErrorJson(input) {
14148
+ return {
14149
+ ok: false,
14150
+ exit_code: input.exitCode,
14151
+ apply_id: null,
14152
+ status: null,
14153
+ steps: [],
14154
+ trigger_wiring: null,
14155
+ problems: { hard: [], strict: [] },
14156
+ error: input.error
14157
+ };
14158
+ }
14025
14159
 
14026
14160
  // src/paymentsCmd.ts
14027
14161
  var SIMULATE_SCENARIOS = [
@@ -19669,12 +19803,19 @@ function hasFlag(name) {
19669
19803
  return rest.includes(`--${name}`);
19670
19804
  }
19671
19805
  var jsonOut = rest.includes("--json");
19806
+ var jsonFailDoc = null;
19672
19807
  function ttlFlag(fallback) {
19673
19808
  const parsed = parseTtlFlag(flag("ttl"), fallback);
19674
19809
  if ("error" in parsed) fail(parsed.error);
19675
19810
  return parsed.hours;
19676
19811
  }
19677
19812
  function fail(msg, code = 1) {
19813
+ if (jsonFailDoc) {
19814
+ const doc = jsonFailDoc;
19815
+ jsonFailDoc = null;
19816
+ process.stdout.write(`${doc(msg, code)}
19817
+ `);
19818
+ }
19678
19819
  console.error(`vxil: ${msg}`);
19679
19820
  process.exit(code);
19680
19821
  }
@@ -20566,22 +20707,79 @@ async function backendListsFunctions(api) {
20566
20707
  }
20567
20708
  }
20568
20709
  async function runServerApply(sel) {
20710
+ armServerApplyJsonFail();
20569
20711
  const { api, target } = requireApi(sel, { write: "push --server" });
20712
+ const say = (s) => {
20713
+ if (jsonOut) console.error(s);
20714
+ else console.log(s);
20715
+ };
20716
+ const strict = hasFlag("strict");
20717
+ const finish = async (done2, problems2, code, error) => {
20718
+ jsonFailDoc = null;
20719
+ if (jsonOut) {
20720
+ const doc = JSON.stringify(serverApplyJson({
20721
+ view: done2,
20722
+ triggerWiring: triggerWiringOf(done2),
20723
+ problems: problems2,
20724
+ exitCode: code,
20725
+ ...error ? { error } : {}
20726
+ }));
20727
+ await new Promise((r) => {
20728
+ process.stdout.write(`${doc}
20729
+ `, () => r());
20730
+ });
20731
+ }
20732
+ if (error) fail(error, code);
20733
+ process.exit(code);
20734
+ };
20735
+ const armWith = (done2, problems2) => {
20736
+ if (!jsonOut) return;
20737
+ jsonFailDoc = (error, exitCode) => JSON.stringify(serverApplyJson({
20738
+ view: done2,
20739
+ triggerWiring: triggerWiringOf(done2),
20740
+ problems: problems2,
20741
+ exitCode,
20742
+ error
20743
+ }));
20744
+ };
20745
+ const report = (done2, wiring) => {
20746
+ say(formatApplySteps(done2.steps));
20747
+ say(`
20748
+ apply ${done2.apply_id}: ${done2.status}`);
20749
+ if (!wiring) return;
20750
+ const w = triggerWiringOf(done2);
20751
+ if (w) say(`
20752
+ ${formatTriggerWiring(w).join("\n")}`);
20753
+ };
20754
+ const gen = async () => {
20755
+ if (hasFlag("no-gen")) return;
20756
+ if (!jsonOut) {
20757
+ await runGen(sel);
20758
+ return;
20759
+ }
20760
+ const log = console.log;
20761
+ console.log = (...a) => {
20762
+ console.error(...a);
20763
+ };
20764
+ try {
20765
+ await runGen(sel);
20766
+ } finally {
20767
+ console.log = log;
20768
+ }
20769
+ };
20570
20770
  const resume = flag("resume");
20571
20771
  if (resume) {
20572
20772
  const done2 = await serverApply({ api, resume });
20573
- console.log(formatApplySteps(done2.steps));
20574
- console.log(`
20575
- apply ${done2.apply_id}: ${done2.status}`);
20576
- if (done2.status === "running") console.log(`resume with: vxil push --server --resume ${done2.apply_id}`);
20577
- if (done2.status !== "succeeded" && done2.status !== "running") process.exit(1);
20578
- if (done2.status === "succeeded" && !hasFlag("no-gen")) await runGen(sel);
20773
+ report(done2, true);
20579
20774
  const resumed = { hard: [], strict: [] };
20775
+ armWith(done2, resumed);
20776
+ if (done2.status === "running") say(`resume with: vxil push --server --resume ${done2.apply_id}`);
20777
+ if (done2.status !== "succeeded" && done2.status !== "running") return finish(done2, resumed, 1);
20778
+ if (done2.status === "succeeded") await gen();
20580
20779
  applyHookProblems(done2, resumed);
20581
- const exitResumed = pushExitCode(resumed, hasFlag("strict"));
20780
+ const exitResumed = pushExitCode(resumed, strict);
20582
20781
  for (const line of exitResumed.lines) console.error(`vxil: ${line}`);
20583
- if (exitResumed.code !== 0) process.exit(exitResumed.code);
20584
- return;
20782
+ return finish(done2, resumed, exitResumed.code);
20585
20783
  }
20586
20784
  const loaded = await loadVxilConfig();
20587
20785
  const cwd = process.cwd();
@@ -20589,7 +20787,7 @@ apply ${done2.apply_id}: ${done2.status}`);
20589
20787
  const cfg = hasFlag("skip-functions") ? rest2 : loaded;
20590
20788
  const fnNames = Object.keys(cfg.functions ?? {});
20591
20789
  if (hasFlag("skip-functions") && declaredFns && Object.keys(declaredFns).length) {
20592
- console.log(`functions: skipped (--skip-functions) \u2014 ${Object.keys(declaredFns).length} declared function(s) not deployed`);
20790
+ say(`functions: skipped (--skip-functions) \u2014 ${Object.keys(declaredFns).length} declared function(s) not deployed`);
20593
20791
  }
20594
20792
  if (fnNames.length) {
20595
20793
  const ent = await probeFunctionsEntitlement(api, fnNames[0]);
@@ -20614,33 +20812,30 @@ apply ${done2.apply_id}: ${done2.status}`);
20614
20812
  bundle,
20615
20813
  allowDestructive: hasFlag("allow-destructive"),
20616
20814
  onPlan: (plan) => {
20617
- console.log(`plan (apply ${plan.apply_id}):`);
20618
- console.log(formatApplySteps(plan.steps));
20619
- for (const w of plan.warnings ?? []) console.log(` ! ${w}`);
20620
- console.log("\napplying server-side\u2026");
20815
+ say(`plan (apply ${plan.apply_id}):`);
20816
+ say(formatApplySteps(plan.steps));
20817
+ for (const w of plan.warnings ?? []) say(` ! ${w}`);
20818
+ say("\napplying server-side\u2026");
20621
20819
  }
20622
20820
  });
20623
- console.log(formatApplySteps(done.steps));
20624
- console.log(`
20625
- apply ${done.apply_id}: ${done.status}`);
20626
20821
  if (done.status === "running") {
20627
20822
  const errored = done.steps.filter((st) => st.status === "pending" && st.error);
20628
20823
  const onlyDeferredApiState = errored.length > 0 && errored.every((st) => st.kind === "api-state" && (st.error ?? "").startsWith("deferred:"));
20629
20824
  if (onlyDeferredApiState) {
20630
- console.log(" \u2026 declared API state deferred (the feature config is still propagating) \u2014 waiting 30 s, then resuming the same apply");
20825
+ report(done, false);
20826
+ say(" \u2026 declared API state deferred (the feature config is still propagating) \u2014 waiting 30 s, then resuming the same apply");
20631
20827
  await new Promise((r) => setTimeout(r, 3e4));
20632
20828
  done = await serverApply({ api, resume: done.apply_id });
20633
- console.log(formatApplySteps(done.steps));
20634
- console.log(`
20635
- apply ${done.apply_id}: ${done.status}`);
20636
20829
  }
20637
20830
  }
20831
+ report(done, true);
20832
+ const problems = { hard: [], strict: [] };
20833
+ armWith(done, problems);
20638
20834
  if (done.status === "running") {
20639
- fail(`a transient step failure interrupted the apply \u2014 resume with: vxil push --server --resume ${done.apply_id}`);
20835
+ return finish(done, problems, 1, `a transient step failure interrupted the apply \u2014 resume with: vxil push --server --resume ${done.apply_id}`);
20640
20836
  }
20641
20837
  const standing = done.steps.filter((st) => st.kind === "api-state" && st.status === "failed");
20642
20838
  const onlyApiStateFailed = done.steps.every((st) => st.status !== "failed" || st.kind === "api-state");
20643
- const problems = { hard: [], strict: [] };
20644
20839
  if (done.status === "partial" && standing.length && onlyApiStateFailed && done.failed_step?.kind === "api-state" && done.steps.every((st) => st.status !== "rolled_back")) {
20645
20840
  for (const st of standing) {
20646
20841
  console.error(`vxil: warning \u2014 ${st.target} not converged: ${st.error ?? ""} (the config stands; fix the cause and re-push)`);
@@ -20648,7 +20843,7 @@ apply ${done.apply_id}: ${done.status}`);
20648
20843
  }
20649
20844
  } else if (done.status !== "succeeded") {
20650
20845
  const f = done.failed_step;
20651
- fail(`apply ${done.status}${f ? ` \u2014 step ${f.seq} (${f.kind} ${f.target}): ${f.error}` : ""} (config/functions were rolled back to their pre-apply versions; cms schema additions were kept)`);
20846
+ return finish(done, problems, 1, `apply ${done.status}${f ? ` \u2014 step ${f.seq} (${f.kind} ${f.target}): ${f.error}` : ""} (config/functions were rolled back to their pre-apply versions; cms schema additions were kept)`);
20652
20847
  }
20653
20848
  applyHookProblems(done, problems);
20654
20849
  if (!hasFlag("skip-functions") && (fnNames.length || cfg.features.functions || await backendListsFunctions(api))) {
@@ -20662,24 +20857,26 @@ apply ${done.apply_id}: ${done.status}`);
20662
20857
  for (const name of deployed.filter((n) => !fnNames.includes(n)).sort()) {
20663
20858
  if (hasFlag("allow-destructive")) {
20664
20859
  const del = await deleteRemoteFunction(api, name);
20665
- console.log(` - ${name} [deployed, not in vxil.config] \u2014 ${del.gone ? "already gone" : "deleted (--allow-destructive)"}`);
20860
+ say(` - ${name} [deployed, not in vxil.config] \u2014 ${del.gone ? "already gone" : "deleted (--allow-destructive)"}`);
20666
20861
  if (del.hook && del.hook.failed > 0) problems.hard.push(`trigger wiring: ${del.hook.failed} subscription(s) failed to reconcile after deleting ${name} \u2014 ${hookReconcileRemedy(fnNames[0])}`);
20667
20862
  } else {
20668
- console.log(` - ${name} [deployed, not in vxil.config] \u2014 kept; \`vxil push --server --allow-destructive\` deletes it`);
20863
+ say(` - ${name} [deployed, not in vxil.config] \u2014 kept; \`vxil push --server --allow-destructive\` deletes it`);
20669
20864
  problems.strict.push(`deployed but not declared (kept): ${name}`);
20670
20865
  }
20671
20866
  }
20672
20867
  }
20673
20868
  }
20674
- if (!hasFlag("no-gen")) await runGen(sel);
20675
- const exitWith = pushExitCode(problems, hasFlag("strict"));
20869
+ await gen();
20870
+ const exitWith = pushExitCode(problems, strict);
20676
20871
  for (const line of exitWith.lines) console.error(`vxil: ${line}`);
20677
- if (exitWith.code !== 0) process.exit(exitWith.code);
20872
+ return finish(done, problems, exitWith.code);
20873
+ }
20874
+ function armServerApplyJsonFail() {
20875
+ if (jsonOut) jsonFailDoc = (error, exitCode) => JSON.stringify(serverApplyErrorJson({ error, exitCode }));
20678
20876
  }
20679
20877
  function applyHookProblems(done, problems) {
20680
20878
  const hook = hookReconcileOf(done);
20681
20879
  if (!hook || hook.failed === 0) return;
20682
- console.error(` ! trigger wiring: ${hook.warning ?? `${hook.failed} trigger subscription(s) could not be written`}`);
20683
20880
  problems.hard.push(`trigger wiring: ${hook.failed} subscription(s) failed to reconcile \u2014 a function may be wired to nothing; re-run \`vxil push --server\` (every executed apply re-runs the reconcile)`);
20684
20881
  }
20685
20882
  async function runSeed(sel, opts = {}) {
@@ -21877,6 +22074,7 @@ ${formatSummary(state)}`);
21877
22074
  break;
21878
22075
  }
21879
22076
  case "push": {
22077
+ if (hasFlag("server") || flag("resume") !== void 0) armServerApplyJsonFail();
21880
22078
  if (hasFlag("prod") && (hasFlag("dev") || flag("target") !== void 0)) fail("--prod cannot be combined with --dev / --target \u2014 pick one target");
21881
22079
  const sel = selector();
21882
22080
  const pushTarget = resolveTargetOrFail(sel);
@@ -22374,6 +22572,8 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
22374
22572
  if (done.skipped > 0) {
22375
22573
  console.log(` \u26A0 ${done.skipped} row(s) were written while the re-index ran and were NOT re-projected \u2014 re-run \`vxil cms reindex ${collection}\` when writes are quiet`);
22376
22574
  }
22575
+ const certifyWarning = indexCertifyWarning(collection, done.indexCertify);
22576
+ if (certifyWarning) console.log(certifyWarning);
22377
22577
  } else {
22378
22578
  fail("usage: vxil cms status|pull|reindex <collection> [--field <field>]|drop <collection> [--yes]|bulk-rm <collection> --filter '<json>' [--dry-run] [--yes]");
22379
22579
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vxil/cli",
3
- "version": "0.11.1",
3
+ "version": "0.13.0",
4
4
  "private": false,
5
5
  "description": "The vxil CLI — init, quickstart, push, gen, secrets, keys, migrate, doctor, diff, functions, payments; installs the `vxil` command (npm i -g @vxil/cli).",
6
6
  "license": "MIT",
@@ -46,9 +46,9 @@
46
46
  },
47
47
  "devDependencies": {
48
48
  "miniflare": "^4.20260611.0",
49
- "@vxil/config": "0.7.0",
50
- "@vxil/feature-configs": "0.7.0",
49
+ "@vxil/config": "0.8.0",
51
50
  "@vxil/runtime": "0.0.1",
51
+ "@vxil/feature-configs": "0.7.0",
52
52
  "@vxil/types": "0.0.1"
53
53
  },
54
54
  "scripts": {