@vxil/cli 0.12.0 → 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.12.0";
4466
- var VXIL_CLI_PKG_VERSION = "0.12.0";
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 = {
@@ -8150,6 +8150,14 @@ function formatLogLines(lines) {
8150
8150
  }
8151
8151
 
8152
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
+ }
8153
8161
  function normalizeActions(raw) {
8154
8162
  if (!Array.isArray(raw)) return [];
8155
8163
  return raw.filter((a) => !!a && typeof a === "object").map((a) => ({ key: String(a.key), label: String(a.label), fn: String(a.fn) }));
@@ -8169,7 +8177,10 @@ var FIELD_ATTRS = [
8169
8177
  // (the server stores NULL for "ungated" and normalizes `[]` to NULL), so an
8170
8178
  // omitted attribute diffs clean; CLEARING it WIDENS access and therefore rides
8171
8179
  // the same --allow-destructive gate as clearing unique/on_delete/owner_field.
8172
- { 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" }
8173
8184
  ];
8174
8185
  function fieldToInput(name, def) {
8175
8186
  const out = { field: name, type: def.type };
@@ -8192,7 +8203,7 @@ function remoteFieldToConfig(rf) {
8192
8203
  function fieldAttrDelta(def, rf) {
8193
8204
  const d = def;
8194
8205
  const r = rf;
8195
- const out = { flags: [], clearing: [], inPlace: [], reindex: [], retarget: [], drift: [] };
8206
+ const out = { flags: [], clearing: [], inPlace: [], reindex: [], retarget: [], index: [], drift: [] };
8196
8207
  for (const a of FIELD_ATTRS) {
8197
8208
  const wantBlank = stableStringify(a.blank);
8198
8209
  const want = stableStringify(d[a.config] ?? a.blank);
@@ -8209,6 +8220,7 @@ function fieldAttrDelta(def, rf) {
8209
8220
  }
8210
8221
  if (a.alter === "reindex") out.reindex.push(line);
8211
8222
  else if (a.alter === "destructive") out.retarget.push(line);
8223
+ else if (a.alter === "index") out.index.push(line);
8212
8224
  else out.inPlace.push(line);
8213
8225
  }
8214
8226
  return out;
@@ -8254,6 +8266,7 @@ async function reindexCollection({ api, collection, field, onProgress }) {
8254
8266
  let updated = 0;
8255
8267
  let pages = 0;
8256
8268
  let skipped = 0;
8269
+ let indexCertify;
8257
8270
  const page = async () => {
8258
8271
  const res = await api("POST", `/v1/cms/collections/${encodeURIComponent(collection)}/reindex`, {
8259
8272
  ...field ? { field } : {},
@@ -8267,6 +8280,7 @@ async function reindexCollection({ api, collection, field, onProgress }) {
8267
8280
  scanned += d.scanned ?? 0;
8268
8281
  updated += d.updated ?? 0;
8269
8282
  pages++;
8283
+ if (d.index_certify) indexCertify = d.index_certify;
8270
8284
  return d;
8271
8285
  };
8272
8286
  for (; ; ) {
@@ -8283,7 +8297,7 @@ async function reindexCollection({ api, collection, field, onProgress }) {
8283
8297
  }
8284
8298
  cursor = d.next_cursor;
8285
8299
  }
8286
- return { scanned, updated, pages, skipped };
8300
+ return { scanned, updated, pages, skipped, ...indexCertify ? { indexCertify } : {} };
8287
8301
  }
8288
8302
  async function planCmsSchema({ api, collections, apply, allowDestructive = false, onProgress }) {
8289
8303
  const read = await readRemote2(api);
@@ -8312,11 +8326,17 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
8312
8326
  const collChanges = [];
8313
8327
  const post = [];
8314
8328
  const slotMoved = [];
8329
+ const indexArming = [];
8330
+ const indexVacating = [];
8331
+ let rowsMemo;
8332
+ const rows = () => rowsMemo ??= liveRowCount(api, name);
8315
8333
  const remoteByName = new Map(rc.fields.map((f) => [f.field, f]));
8316
8334
  for (const [fn, fd] of declaredFields) {
8317
8335
  const existing = remoteByName.get(fn);
8318
8336
  if (!existing) {
8319
- 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}` });
8320
8340
  } else if (existing.type !== fd.type) {
8321
8341
  if (allowDestructive && (fd.indexSlot ?? null) !== (existing.index_slot ?? null)) {
8322
8342
  if (existing.index_slot) {
@@ -8339,10 +8359,14 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
8339
8359
  detail: `${name}.${fn} type ${existing.type} \u2192 ${fd.type} (destructive alter)`
8340
8360
  });
8341
8361
  } else {
8342
- 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);
8343
8367
  const destructiveAttrs = [...clearing, ...reindex, ...retarget];
8344
8368
  if (destructiveAttrs.length) {
8345
- const all = [...destructiveAttrs, ...flags, ...inPlace];
8369
+ const all = [...destructiveAttrs, ...flags, ...inPlace, ...index];
8346
8370
  if (allowDestructive) {
8347
8371
  if (reindex.length && existing.index_slot) {
8348
8372
  pre.push({
@@ -8359,7 +8383,7 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
8359
8383
  kind: "destructive",
8360
8384
  op: "alter-field",
8361
8385
  field: fn,
8362
- 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)"
8363
8387
  });
8364
8388
  if (reindex.length) slotMoved.push(fn);
8365
8389
  } else {
@@ -8370,12 +8394,12 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
8370
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."
8371
8395
  });
8372
8396
  }
8373
- } else if (flags.length || inPlace.length) {
8397
+ } else if (flags.length || inPlace.length || index.length) {
8374
8398
  collChanges.push({
8375
8399
  collection: name,
8376
8400
  kind: "alter-flags",
8377
8401
  field: fn,
8378
- 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}`
8379
8403
  });
8380
8404
  }
8381
8405
  if (drift.length) {
@@ -8439,12 +8463,25 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
8439
8463
  });
8440
8464
  }
8441
8465
  }
8442
- if (slotMoved.length) {
8443
- 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
+ ];
8444
8481
  post.push({
8445
8482
  collection: name,
8446
8483
  kind: "reindex",
8447
- 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 ")}`
8448
8485
  });
8449
8486
  }
8450
8487
  changes.push(...pre, ...collChanges, ...post);
@@ -8534,6 +8571,8 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
8534
8571
  if (done.skipped > 0) {
8535
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");
8536
8573
  }
8574
+ const certifyWarning = indexCertifyWarning(c.collection, done.indexCertify);
8575
+ if (certifyWarning) onProgress?.(certifyWarning);
8537
8576
  applied++;
8538
8577
  } else if (c.kind === "destructive") {
8539
8578
  applied += await applyDestructive(api, collections, c);
@@ -8608,6 +8647,10 @@ function formatCollectionDrop(collection, plan, declaredLocally) {
8608
8647
  }
8609
8648
  return lines.join("\n");
8610
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
+ }
8611
8654
 
8612
8655
  // src/apiCmd.ts
8613
8656
  var API_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
@@ -11733,7 +11776,7 @@ var TOOLS = [
11733
11776
  {
11734
11777
  name: "cms_list_collections",
11735
11778
  feature: "cms",
11736
- 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.",
11737
11780
  inputSchema: { type: "object", properties: {} },
11738
11781
  method: "GET",
11739
11782
  path: "/v1/cms/collections"
@@ -11757,7 +11800,7 @@ var TOOLS = [
11757
11800
  {
11758
11801
  name: "cms_query_items",
11759
11802
  feature: "cms",
11760
- 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).',
11761
11804
  inputSchema: {
11762
11805
  type: "object",
11763
11806
  properties: {
@@ -22529,6 +22572,8 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
22529
22572
  if (done.skipped > 0) {
22530
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`);
22531
22574
  }
22575
+ const certifyWarning = indexCertifyWarning(collection, done.indexCertify);
22576
+ if (certifyWarning) console.log(certifyWarning);
22532
22577
  } else {
22533
22578
  fail("usage: vxil cms status|pull|reindex <collection> [--field <field>]|drop <collection> [--yes]|bulk-rm <collection> --filter '<json>' [--dry-run] [--yes]");
22534
22579
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vxil/cli",
3
- "version": "0.12.0",
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,10 +46,10 @@
46
46
  },
47
47
  "devDependencies": {
48
48
  "miniflare": "^4.20260611.0",
49
- "@vxil/config": "0.7.0",
49
+ "@vxil/config": "0.8.0",
50
50
  "@vxil/runtime": "0.0.1",
51
- "@vxil/types": "0.0.1",
52
- "@vxil/feature-configs": "0.7.0"
51
+ "@vxil/feature-configs": "0.7.0",
52
+ "@vxil/types": "0.0.1"
53
53
  },
54
54
  "scripts": {
55
55
  "build": "pnpm --filter @vxil/feature-configs run build && pnpm --filter @vxil/config run build && esbuild bin/vxil.ts --bundle --platform=node --format=esm --target=node24 --outfile=dist/vxil.js --banner:js='#!/usr/bin/env node' --external:esbuild --external:miniflare && esbuild src/config-entry.ts --bundle --platform=node --format=esm --target=node24 --outfile=dist/config.js && pnpm exec tsx scripts/build-config-dts.ts",