@vxil/config 0.6.0 → 0.8.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/index.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.
@@ -132,9 +147,15 @@ export interface FunctionTriggerRetry {
132
147
  export type FunctionTrigger = {
133
148
  kind: 'http';
134
149
  path?: string;
135
- } | {
150
+ }
151
+ /** `overlap: 'skip'` (2026-10-01): a due tick fires nothing while the
152
+ * previous tick's run is still open (queued / running / retrying / waiting /
153
+ * delayed) — the slot is counted, never caught up. Default `'allow'`. Every
154
+ * cron envelope carries `scheduled_for`, the slot it was due for. */
155
+ | {
136
156
  kind: 'cron';
137
157
  schedule: string;
158
+ overlap?: 'allow' | 'skip';
138
159
  } | {
139
160
  kind: 'queue';
140
161
  source: string;
package/dist/index.js CHANGED
@@ -1,3 +1,25 @@
1
+ /** The per-collection budget of `indexed: true` fields (cms/0158: e1…e4). */
2
+ export const CMS_INDEXED_FIELD_BUDGET = 4;
3
+ /** Define-time checks of a collection's `indexed` fields — the same refusals
4
+ * the server makes (422), so a config fails at `defineConfig` / `collection()`
5
+ * instead of mid-push. Throws on the first violation. */
6
+ export function assertIndexedFields(name, def) {
7
+ let n = 0;
8
+ for (const [fname, f] of Object.entries(def.fields ?? {})) {
9
+ if (f.indexed !== true)
10
+ continue;
11
+ n += 1;
12
+ if (f.indexSlot) {
13
+ throw new Error(`vxil.config: cms.${name}.${fname} sets both indexed and indexSlot '${f.indexSlot}' — a slot already indexes equality; use one`);
14
+ }
15
+ if (f.computed) {
16
+ throw new Error(`vxil.config: cms.${name}.${fname} is computed — a computed field cannot be indexed (bind it to an indexSlot instead)`);
17
+ }
18
+ }
19
+ if (n > CMS_INDEXED_FIELD_BUDGET) {
20
+ throw new Error(`vxil.config: cms.${name} has ${n} indexed fields — at most ${CMS_INDEXED_FIELD_BUDGET} per collection`);
21
+ }
22
+ }
1
23
  /** The released majors `defineConfig` validates `apiVersion` against. Widened
2
24
  * only by a real v2. */
3
25
  export const RELEASED_API_VERSIONS = ['v1'];
@@ -11,6 +33,8 @@ export function defineConfig(c) {
11
33
  `(released: ${RELEASED_API_VERSIONS.map((v) => `'${v}'`).join(', ')}). ` +
12
34
  'A new major ships as a new path with its own deprecation window — see https://vxil.com/docs/guide/12-going-to-production.');
13
35
  }
36
+ for (const [name, def] of Object.entries(c.cms?.collections ?? {}))
37
+ assertIndexedFields(name, def);
14
38
  return c;
15
39
  }
16
40
  /** Authoring helper for split-out cms/<name>.ts collection modules (§6a).
@@ -20,5 +44,6 @@ export function field(type, opts = {}) {
20
44
  }
21
45
  /** Authoring helper for a collection module: `export default collection({...})`. */
22
46
  export function collection(def) {
47
+ assertIndexedFields(def.singular ?? 'collection', def);
23
48
  return def;
24
49
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vxil/config",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Typed vxil.config.ts authoring for the vxil backend platform — defineConfig, feature and cms field definitions (published for @vxil/cli).",
5
5
  "license": "MIT",
6
6
  "homepage": "https://vxil.com",
@@ -22,7 +22,7 @@
22
22
  "src"
23
23
  ],
24
24
  "dependencies": {
25
- "@vxil/feature-configs": "0.6.0"
25
+ "@vxil/feature-configs": "0.7.0"
26
26
  },
27
27
  "publishConfig": {
28
28
  "access": "public"
package/src/index.test.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { defineConfig, RELEASED_API_VERSIONS } from './index.js';
2
+ import {
3
+ assertIndexedFields, CMS_INDEXED_FIELD_BUDGET, collection, defineConfig, field, RELEASED_API_VERSIONS,
4
+ } from './index.js';
3
5
 
4
6
  describe('defineConfig', () => {
5
7
  it('is an identity function (returns its argument verbatim)', () => {
@@ -26,3 +28,25 @@ describe('defineConfig', () => {
26
28
  .toThrow(/docs\/guide\/12-going-to-production/);
27
29
  });
28
30
  });
31
+
32
+ describe('indexed fields (PERF-26b) — define-time checks', () => {
33
+ it('accepts up to four unslotted indexed fields', () => {
34
+ const c = collection({ fields: {
35
+ a: field('string', { indexed: true }), b: field('int', { indexed: true }),
36
+ c: field('bool', { indexed: true }), d: field('string', { indexed: true }),
37
+ e: field('string', { indexSlot: 's1' }),
38
+ } });
39
+ expect(Object.keys(c.fields)).toHaveLength(5);
40
+ expect(CMS_INDEXED_FIELD_BUDGET).toBe(4);
41
+ });
42
+
43
+ it('refuses a fifth, indexed + indexSlot, and a computed indexed field', () => {
44
+ expect(() => defineConfig({ cms: { collections: { posts: { fields: Object.fromEntries(
45
+ ['a', 'b', 'c', 'd', 'e'].map((k) => [k, field('string', { indexed: true })]),
46
+ ) } } } })).toThrow(/at most 4 per collection/);
47
+ expect(() => collection({ fields: { a: field('string', { indexed: true, indexSlot: 's1' }) } }))
48
+ .toThrow(/already indexes equality/);
49
+ expect(() => assertIndexedFields('posts', { fields: { a: field('string', { indexed: true, computed: true }) } }))
50
+ .toThrow(/computed/);
51
+ });
52
+ });
package/src/index.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  // types); the control plane never executes this file. This keeps config-as-code
7
7
  // §7.3-consistent — config is DATA; the only tenant CODE that crosses into vxil
8
8
  // is (a) the Lane-A hook expressions (a closed sandbox) and (b) the functions/
9
- // sources (the paid, opt-in, egress-guarded crossing). Everything here is
9
+ // sources (the opt-in, plan-limited, egress-guarded crossing). Everything here is
10
10
  // declarative. See https://vxil.com/docs/guide/05-typed-sdk-and-cli.
11
11
  import type {
12
12
  NotificationsConfig, JobsConfig, AuthConfig, RateLimitsConfig, FilesConfig,
@@ -59,6 +59,38 @@ export interface FieldDef {
59
59
  * `orgs` role alphabet). The gate
60
60
  * never affects WRITES. Carried by BOTH push paths and alterable in place. */
61
61
  readRoles?: string[];
62
+ /** EQUALITY INDEX for an UNSLOTTED field (cms.md §3 "Indexed equality"):
63
+ * `=` / `$eq` / `$in` filters on it are index-served instead of a bounded
64
+ * scan — results are identical either way. At most 4 per collection (the
65
+ * equality-key budget, separate from the 8 index slots); not together with
66
+ * `indexSlot` (a slot already indexes equality) and not on a computed field
67
+ * (both checked here at define time and again by the server). Carried by
68
+ * BOTH push paths and alterable in place; on a collection over 2,000 live
69
+ * items `vxil push` also runs the re-index that arms it. */
70
+ indexed?: boolean;
71
+ }
72
+
73
+ /** The per-collection budget of `indexed: true` fields (cms/0158: e1…e4). */
74
+ export const CMS_INDEXED_FIELD_BUDGET = 4;
75
+
76
+ /** Define-time checks of a collection's `indexed` fields — the same refusals
77
+ * the server makes (422), so a config fails at `defineConfig` / `collection()`
78
+ * instead of mid-push. Throws on the first violation. */
79
+ export function assertIndexedFields(name: string, def: CollectionDef): void {
80
+ let n = 0;
81
+ for (const [fname, f] of Object.entries(def.fields ?? {})) {
82
+ if (f.indexed !== true) continue;
83
+ n += 1;
84
+ if (f.indexSlot) {
85
+ throw new Error(`vxil.config: cms.${name}.${fname} sets both indexed and indexSlot '${f.indexSlot}' — a slot already indexes equality; use one`);
86
+ }
87
+ if (f.computed) {
88
+ throw new Error(`vxil.config: cms.${name}.${fname} is computed — a computed field cannot be indexed (bind it to an indexSlot instead)`);
89
+ }
90
+ }
91
+ if (n > CMS_INDEXED_FIELD_BUDGET) {
92
+ throw new Error(`vxil.config: cms.${name} has ${n} indexed fields — at most ${CMS_INDEXED_FIELD_BUDGET} per collection`);
93
+ }
62
94
  }
63
95
 
64
96
  /** One config-declared per-record ACTION button (cms.md §17): exactly ONE
@@ -156,7 +188,11 @@ export interface FunctionTriggerRetry {
156
188
  * post-signup hook; at-least-once, ~1min fanout latency). */
157
189
  export type FunctionTrigger =
158
190
  | { kind: 'http'; path?: string }
159
- | { kind: 'cron'; schedule: string }
191
+ /** `overlap: 'skip'` (2026-10-01): a due tick fires nothing while the
192
+ * previous tick's run is still open (queued / running / retrying / waiting /
193
+ * delayed) — the slot is counted, never caught up. Default `'allow'`. Every
194
+ * cron envelope carries `scheduled_for`, the slot it was due for. */
195
+ | { kind: 'cron'; schedule: string; overlap?: 'allow' | 'skip' }
160
196
  | { kind: 'queue'; source: string; retry?: FunctionTriggerRetry }
161
197
  /** `webhook`: the function is woken by a webhook_subscription fan-out whose
162
198
  * target_url is `<edge>/v1/internal/fn/trigger/<name>?tenant=<id>`. That
@@ -333,6 +369,7 @@ export function defineConfig(c: VxilConfig): VxilConfig {
333
369
  'A new major ships as a new path with its own deprecation window — see https://vxil.com/docs/guide/12-going-to-production.',
334
370
  );
335
371
  }
372
+ for (const [name, def] of Object.entries(c.cms?.collections ?? {})) assertIndexedFields(name, def);
336
373
  return c;
337
374
  }
338
375
 
@@ -347,5 +384,6 @@ export function field(
347
384
 
348
385
  /** Authoring helper for a collection module: `export default collection({...})`. */
349
386
  export function collection(def: CollectionDef): CollectionDef {
387
+ assertIndexedFields(def.singular ?? 'collection', def);
350
388
  return def;
351
389
  }