@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 +22 -1
- package/dist/index.js +25 -0
- package/package.json +2 -2
- package/src/index.test.ts +25 -1
- package/src/index.ts +40 -2
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.
|
|
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.
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
}
|