@substrio/cli 0.0.0-stage → 0.1.1

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.
Files changed (56) hide show
  1. package/LICENSE +177 -0
  2. package/README.md +66 -2
  3. package/bin/substrio.mjs +91 -0
  4. package/package.json +44 -4
  5. package/src/api.mjs +107 -0
  6. package/src/apply.mjs +204 -0
  7. package/src/blobs.mjs +102 -0
  8. package/src/cli.mjs +405 -0
  9. package/src/collections.mjs +701 -0
  10. package/src/command-table.mjs +436 -0
  11. package/src/commands/bundle.mjs +369 -0
  12. package/src/commands/count.mjs +39 -0
  13. package/src/commands/deploy.mjs +488 -0
  14. package/src/commands/dev.mjs +246 -0
  15. package/src/commands/env.mjs +115 -0
  16. package/src/commands/files.mjs +172 -0
  17. package/src/commands/git.mjs +195 -0
  18. package/src/commands/groups-runtime.mjs +227 -0
  19. package/src/commands/groups.mjs +417 -0
  20. package/src/commands/history.mjs +374 -0
  21. package/src/commands/import.mjs +506 -0
  22. package/src/commands/init.mjs +175 -0
  23. package/src/commands/key.mjs +172 -0
  24. package/src/commands/login.mjs +167 -0
  25. package/src/commands/logs.mjs +285 -0
  26. package/src/commands/mcp-config.mjs +102 -0
  27. package/src/commands/op.mjs +265 -0
  28. package/src/commands/open.mjs +35 -0
  29. package/src/commands/push.mjs +199 -0
  30. package/src/commands/signup.mjs +164 -0
  31. package/src/commands/site.mjs +129 -0
  32. package/src/commands/skills.mjs +177 -0
  33. package/src/commands/status.mjs +78 -0
  34. package/src/commands/tenants.mjs +172 -0
  35. package/src/commands/test.mjs +167 -0
  36. package/src/commands/tools.mjs +63 -0
  37. package/src/commands/versions.mjs +293 -0
  38. package/src/config.mjs +569 -0
  39. package/src/documents.mjs +139 -0
  40. package/src/function-build.mjs +273 -0
  41. package/src/identity.mjs +248 -0
  42. package/src/jsonfile.mjs +22 -0
  43. package/src/live-move.mjs +267 -0
  44. package/src/local/args.mjs +58 -0
  45. package/src/local/reload.mjs +408 -0
  46. package/src/local/server.mjs +291 -0
  47. package/src/local/watch.mjs +175 -0
  48. package/src/manifest.mjs +591 -0
  49. package/src/ops.mjs +265 -0
  50. package/src/readers.mjs +416 -0
  51. package/src/sdk.mjs +26 -0
  52. package/src/settings.mjs +117 -0
  53. package/src/testing/app.mjs +507 -0
  54. package/src/testing/env.mjs +35 -0
  55. package/src/testing/index.mjs +11 -0
  56. package/src/testing/types.mjs +55 -0
@@ -0,0 +1,701 @@
1
+ /**
2
+ * Substrio CLI: the collections an app declares, one file each:
3
+ *
4
+ * collections/events.json the directory is substrio.toml's collections_dir
5
+ * { "schema": { "rules": [...], "strict": true }, as schema.update takes it
6
+ * "rules": { "read": "allow", "write": "auth" }, this collection's entry of the security rules
7
+ * "indexes": [...], every key is optional
8
+ * "expire": { "field": "...", "after_ms": 0, "existing": "refuse" },
9
+ * "id": { "derive": "..." },
10
+ * "aggregates": [...] }
11
+ *
12
+ * The file's name is the collection's. Applied idempotently: the collection is
13
+ * made when it is not there, its schema replaced by the declared one, and its
14
+ * rules join the app's security rules (rules_file, then these, one
15
+ * security.rules.set body). A collection whose rules are in rules_file and in
16
+ * its own file is refused, naming both. `substrio deploy` and `substrio dev` apply them
17
+ * with the server's operations (collectionSteps); `substrio push` carries them in
18
+ * the version, with the other members, when the server's versions take them.
19
+ * Nothing here talks to the server except applyCollections and collectionDrift
20
+ * / pruneDrift, through the `invoke` they are given.
21
+ *
22
+ * Every declaration this engine takes for a collection (index.create's kind, see
23
+ * src/substrio/api/substrio_sql_http.cpp SubstrioAPI::handle_index_create) is one entry of `indexes` or `expire`,
24
+ * passed through under the server's own names, nothing translated:
25
+ *
26
+ * server declaration (index.create) file entry (one of `indexes`)
27
+ * --------------------------------------------------- ------------------------------------------------
28
+ * kind collection, field "field.path"
29
+ * kind collection, field, unique { field, unique: true }
30
+ * kind collection, field, keyword, keyword_term_bytes? { field, keyword: true, keyword_term_bytes? }
31
+ * kind collection, fields, unique: true, nulls?, within? { fields: [...], unique: true, nulls?, within? }
32
+ * kind collection, field, elements { field, elements: [...] }
33
+ * kind collection, compound { compound: [...] }
34
+ * kind aggregate one entry of `aggregates` (AggregateKey), not `indexes`
35
+ * kind expiry the file's `expire` (one per collection, not an array)
36
+ * kind id_derive the file's `id.derive` (one per collection, UM-3d)
37
+ * kind space (spans collections) not here: it is not one collection's; it belongs in a
38
+ * project-level file, which does not exist yet
39
+ *
40
+ * Each declared index is sent after the collection and its schema with if_not_exists, so applying twice changes
41
+ * nothing; the server builds the field's keys before it answers, and a unique declaration over documents that
42
+ * already share a value is refused (UNIQUE or INDEX_DUPLICATES) and stored nowhere. The declared `id.derive` is
43
+ * one `index.create` of kind id_derive, sent right after the collection is made (UM-3d): idempotent for its own
44
+ * value on its own (no if_not_exists), refused when it would CHANGE the prefix of a collection that already
45
+ * holds an object. The declared `expire` is one `index.create` of kind expiry, sent after the indexes, with
46
+ * if_not_exists (a second declaration of the same collection is a no-op, not a conflict). Each declared
47
+ * aggregate ({name, group?, metrics, where?}) is one `index.create` of kind aggregate, sent after `expire`, in
48
+ * file order; it builds its groups before it answers and is idempotent for the same name and shape on its own
49
+ * (no if_not_exists: a different shape under the same name is refused).
50
+ *
51
+ * collectionDrift reads what the server has declared for `collections` (index.list kind=collection, aggregate,
52
+ * expiry) and reports what the file no longer names (a field's undeclared adaptive key is never in that answer,
53
+ * so it is never reported); `substrio deploy --prune` and `substrio dev --prune` call pruneDrift to drop it with
54
+ * index.drop. Without --prune a drift is only reported, left on the server.
55
+ */
56
+
57
+ import { existsSync, readdirSync } from 'node:fs';
58
+ import { basename, extname, join, resolve } from 'node:path';
59
+
60
+ import { sha256 } from './blobs.mjs';
61
+ import { CliError, ErrorCode } from './cli.mjs';
62
+ import { readJsonFile } from './jsonfile.mjs';
63
+ import { PAGE_CURSOR_INPUT, PAGE_NEXT_CURSOR_FIELD, buildRequest, findOperation, pickRoute } from './ops.mjs';
64
+
65
+ /** The directory of collection files unless substrio.toml's collections_dir names another. */
66
+ export const DEFAULT_COLLECTIONS_DIR = 'collections';
67
+ /** The version member that carries the collections. */
68
+ export const COLLECTIONS_MEMBER = 'collections';
69
+
70
+ /** The keys of a collection file. */
71
+ export const CollectionKey = Object.freeze({
72
+ SCHEMA: 'schema',
73
+ RULES: 'rules',
74
+ INDEXES: 'indexes',
75
+ EXPIRE: 'expire',
76
+ AGGREGATES: 'aggregates',
77
+ ID: 'id',
78
+ });
79
+ const KEYS = new Set(Object.values(CollectionKey));
80
+
81
+ /** The keys of one entry of a collection file's `indexes`: a single field, a compound unique, an element tuple or a compound index. */
82
+ export const IndexKey = Object.freeze({
83
+ FIELD: 'field',
84
+ FIELDS: 'fields',
85
+ UNIQUE: 'unique',
86
+ NULLS: 'nulls',
87
+ WITHIN: 'within',
88
+ KEYWORD: 'keyword',
89
+ KEYWORD_TERM_BYTES: 'keyword_term_bytes',
90
+ ELEMENTS: 'elements',
91
+ COMPOUND: 'compound',
92
+ });
93
+ const INDEX_KEYS = new Set([IndexKey.FIELD, IndexKey.UNIQUE, IndexKey.KEYWORD, IndexKey.KEYWORD_TERM_BYTES]);
94
+ const FIELDS_KEYS = new Set([IndexKey.FIELDS, IndexKey.UNIQUE, IndexKey.NULLS, IndexKey.WITHIN]);
95
+ const ELEMENTS_KEYS = new Set([IndexKey.FIELD, IndexKey.ELEMENTS]);
96
+ const COMPOUND_KEYS = new Set([IndexKey.COMPOUND]);
97
+
98
+ /** The shape an entry of `indexes` normalizes to (indexEntry's `type`). */
99
+ export const IndexEntryKind = Object.freeze({
100
+ FIELD: 'field',
101
+ FIELDS: 'fields',
102
+ ELEMENTS: 'elements',
103
+ COMPOUND: 'compound',
104
+ });
105
+
106
+ /** The keys of the collection file's `expire`. */
107
+ export const ExpireKey = Object.freeze({
108
+ FIELD: 'field',
109
+ AFTER_MS: 'after_ms',
110
+ EXISTING: 'existing',
111
+ });
112
+ const EXPIRE_KEYS = new Set(Object.values(ExpireKey));
113
+
114
+ /** The keys of the collection file's `id`. */
115
+ export const IdKey = Object.freeze({
116
+ DERIVE: 'derive',
117
+ });
118
+ const ID_KEYS = new Set(Object.values(IdKey));
119
+ const ID_DERIVE_MAX_BYTES = 64;
120
+
121
+ /** The keys of one entry of a collection file's `aggregates`. */
122
+ export const AggregateKey = Object.freeze({
123
+ NAME: 'name',
124
+ GROUP: 'group',
125
+ METRICS: 'metrics',
126
+ WHERE: 'where',
127
+ });
128
+ const AGGREGATE_KEYS = new Set(Object.values(AggregateKey));
129
+
130
+ /** The HTTP status of "that collection exists already" (collection.create). */
131
+ const EXISTS = 409;
132
+
133
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
134
+
135
+ /** The directory name substrio.toml gives (collections_dir), or the default. */
136
+ export const collectionsDirOf = (config) => config.collections_dir || DEFAULT_COLLECTIONS_DIR;
137
+
138
+ /**
139
+ * One entry of a collection file's `indexes`, normalized by shape (pure): a field name or {field, unique?,
140
+ * keyword?, keyword_term_bytes?} is IndexEntryKind.FIELD ({field, unique, keyword, termBytes}); {fields, unique:
141
+ * true, nulls?, within?} is FIELDS, a compound unique declaration ({fields, nulls, within}); {field, elements}
142
+ * is ELEMENTS, an element tuple ({field, elements}); {compound} is COMPOUND, an ordered compound index
143
+ * ({compound}). A field name alone, or {field, unique: false}, is an index that is not unique.
144
+ */
145
+ export const indexEntry = (entry) => {
146
+ if (typeof entry === 'string') return { type: IndexEntryKind.FIELD, field: entry, unique: false, keyword: false, termBytes: undefined };
147
+ if (Object.hasOwn(entry, IndexKey.COMPOUND)) return { type: IndexEntryKind.COMPOUND, compound: entry[IndexKey.COMPOUND] };
148
+ if (Object.hasOwn(entry, IndexKey.ELEMENTS)) return { type: IndexEntryKind.ELEMENTS, field: entry.field, elements: entry[IndexKey.ELEMENTS] };
149
+ if (Object.hasOwn(entry, IndexKey.FIELDS)) {
150
+ return { type: IndexEntryKind.FIELDS, fields: entry[IndexKey.FIELDS], nulls: entry[IndexKey.NULLS], within: entry[IndexKey.WITHIN] };
151
+ }
152
+ return { type: IndexEntryKind.FIELD, field: entry.field, unique: entry.unique === true, keyword: entry.keyword === true, termBytes: entry[IndexKey.KEYWORD_TERM_BYTES] };
153
+ };
154
+
155
+ /** The `index.create` input of one declared index entry (pure), under the server's own kind and names. */
156
+ export const indexCreateInput = (collection, entry) => {
157
+ if (entry.type === IndexEntryKind.COMPOUND) return { kind: 'collection', collection, compound: entry.compound, if_not_exists: true };
158
+ if (entry.type === IndexEntryKind.ELEMENTS) return { kind: 'collection', collection, field: entry.field, elements: entry.elements, if_not_exists: true };
159
+ if (entry.type === IndexEntryKind.FIELDS) {
160
+ return {
161
+ kind: 'collection', collection, fields: entry.fields, unique: true,
162
+ ...(entry.nulls === undefined ? {} : { nulls: entry.nulls }),
163
+ ...(entry.within === undefined ? {} : { within: entry.within }),
164
+ if_not_exists: true,
165
+ };
166
+ }
167
+ return entry.keyword
168
+ ? { kind: 'collection', collection, field: entry.field, keyword: true, ...(entry.termBytes === undefined ? {} : { keyword_term_bytes: entry.termBytes }), if_not_exists: true }
169
+ : { kind: 'collection', collection, field: entry.field, unique: entry.unique, if_not_exists: true };
170
+ };
171
+
172
+ /**
173
+ * The identity of one declared index entry, normalized by indexEntry (pure): matches serverIndexIdentity's of
174
+ * the same declaration read back from index.list, so collectionDrift never reports a declaration the file
175
+ * still names in a different but equivalent form (a plain and a unique index on the same field are still two
176
+ * different declarations, by design: they are never the same identity).
177
+ */
178
+ function fileIndexIdentity(entry) {
179
+ if (entry.type === IndexEntryKind.COMPOUND) return `compound:${entry.compound.join(',')}`;
180
+ if (entry.type === IndexEntryKind.ELEMENTS) return `elements:${entry.field}`;
181
+ if (entry.type === IndexEntryKind.FIELDS) return `unique:${[...entry.fields].sort().join(',')}`;
182
+ return entry.keyword ? `keyword:${entry.field}` : `field:${entry.field}`;
183
+ }
184
+
185
+ /** The identity of one index.list kind=collection entry (the server's own answer), matched against fileIndexIdentity. */
186
+ function serverIndexIdentity(entry) {
187
+ if (Object.hasOwn(entry, IndexKey.COMPOUND)) return `compound:${entry.compound.join(',')}`;
188
+ if (Object.hasOwn(entry, IndexKey.ELEMENTS)) return `elements:${entry.field}`;
189
+ if (Object.hasOwn(entry, IndexKey.KEYWORD)) return `keyword:${entry.field}`;
190
+ if (Object.hasOwn(entry, IndexKey.FIELDS)) return `unique:${[...entry.fields].sort().join(',')}`;
191
+ return `field:${entry.field}`;
192
+ }
193
+
194
+ // One index.list kind=collection entry, as a drift report's label (pure).
195
+ function serverIndexLabel(entry) {
196
+ if (Object.hasOwn(entry, IndexKey.COMPOUND)) return `compound (${entry.compound.join(', ')})`;
197
+ if (Object.hasOwn(entry, IndexKey.ELEMENTS)) return `${entry.field} elements (${entry.elements.join(', ')})`;
198
+ if (Object.hasOwn(entry, IndexKey.KEYWORD)) return `${entry.field} (keyword)`;
199
+ if (Object.hasOwn(entry, IndexKey.FIELDS)) return `unique (${entry.fields.join(', ')})`;
200
+ return entry.unique ? `${entry.field} (unique)` : entry.field;
201
+ }
202
+
203
+ // The `index.drop` input that removes one index.list kind=collection entry (pure); `name` fills the route's
204
+ // {name}, ignored by the server when `fields` or `compound` names the declaration instead (index_declaration_options,
205
+ // handle_index_drop), so any of the declaration's own fields does for it.
206
+ function serverIndexDropInput(collection, entry) {
207
+ if (Object.hasOwn(entry, IndexKey.COMPOUND)) return { kind: 'collection', collection, name: entry.compound[0], compound: entry.compound };
208
+ if (Object.hasOwn(entry, IndexKey.ELEMENTS)) return { kind: 'collection', collection, name: entry.field, elements: entry.elements };
209
+ if (Object.hasOwn(entry, IndexKey.KEYWORD)) return { kind: 'collection', collection, name: entry.field, keyword: true };
210
+ if (Object.hasOwn(entry, IndexKey.FIELDS)) return { kind: 'collection', collection, name: entry.fields[0], fields: entry.fields };
211
+ return { kind: 'collection', collection, name: entry.field };
212
+ }
213
+
214
+ /**
215
+ * Whether `field` is a field path the server takes (pure): a top-level name or member names joined by single
216
+ * dots, no segment empty ("a..b", ".a" and "a." are not).
217
+ */
218
+ export const isFieldPath = (field) => typeof field === 'string' && field !== '' && field.split('.').every((segment) => segment !== '');
219
+
220
+ // `compound`, checked: an array of two or more field paths, the lead fields then the order field. Returns it as read.
221
+ function checkedCompoundEntry(where, entry) {
222
+ const unknown = Object.keys(entry).find((key) => !COMPOUND_KEYS.has(key));
223
+ if (unknown !== undefined) throw new CliError(`${where} has unknown key "${unknown}"; a compound entry takes compound`, { code: ErrorCode.INVALID_ARGUMENT });
224
+ const compound = entry[IndexKey.COMPOUND];
225
+ if (!Array.isArray(compound) || compound.length < 2 || !compound.every(isFieldPath)) {
226
+ throw new CliError(`${where}: compound must be an array of two or more field names, the lead fields then the order field`, { code: ErrorCode.INVALID_ARGUMENT });
227
+ }
228
+ return compound;
229
+ }
230
+
231
+ // {field, elements}, checked: field a path, elements one or more member paths of its array. Returns elements as read.
232
+ function checkedElementsEntry(where, entry) {
233
+ const unknown = Object.keys(entry).find((key) => !ELEMENTS_KEYS.has(key));
234
+ if (unknown !== undefined) throw new CliError(`${where} has unknown key "${unknown}"; an elements entry takes field, elements`, { code: ErrorCode.INVALID_ARGUMENT });
235
+ if (!isFieldPath(entry.field)) {
236
+ throw new CliError(`${where}: field must be the name of a field of the collection or a dotted path into one (no empty segment)`, { code: ErrorCode.INVALID_ARGUMENT });
237
+ }
238
+ const elements = entry[IndexKey.ELEMENTS];
239
+ if (!Array.isArray(elements) || elements.length === 0 || !elements.every(isFieldPath)) {
240
+ throw new CliError(`${where}: elements must be an array of one or more field names, the array's members`, { code: ErrorCode.INVALID_ARGUMENT });
241
+ }
242
+ return elements;
243
+ }
244
+
245
+ // {fields, unique: true, nulls?, within?}, checked: a compound unique declaration. Returns fields as read.
246
+ function checkedFieldsEntry(where, entry) {
247
+ const unknown = Object.keys(entry).find((key) => !FIELDS_KEYS.has(key));
248
+ if (unknown !== undefined) throw new CliError(`${where} has unknown key "${unknown}"; a compound unique entry takes fields, unique, nulls, within`, { code: ErrorCode.INVALID_ARGUMENT });
249
+ if (entry[IndexKey.UNIQUE] !== true) {
250
+ throw new CliError(`${where}: fields is a compound unique declaration; unique must be true`, { code: ErrorCode.INVALID_ARGUMENT });
251
+ }
252
+ const fields = entry[IndexKey.FIELDS];
253
+ if (!Array.isArray(fields) || fields.length < 2 || !fields.every(isFieldPath)) {
254
+ throw new CliError(`${where}: fields must be an array of two or more field names`, { code: ErrorCode.INVALID_ARGUMENT });
255
+ }
256
+ const nulls = entry[IndexKey.NULLS];
257
+ if (nulls !== undefined && nulls !== 'skip' && nulls !== 'count') {
258
+ throw new CliError(`${where}: nulls must be skip or count`, { code: ErrorCode.INVALID_ARGUMENT });
259
+ }
260
+ const within = entry[IndexKey.WITHIN];
261
+ if (within !== undefined && within !== 'collection' && within !== 'document' && within !== 'both') {
262
+ throw new CliError(`${where}: within must be collection, document or both`, { code: ErrorCode.INVALID_ARGUMENT });
263
+ }
264
+ return fields;
265
+ }
266
+
267
+ // The `indexes` of a collection file, checked entry by entry: a field name or path, {field, unique?}, {fields,
268
+ // unique: true, nulls?, within?}, {field, elements} or {compound}; each declaration named once. Returns them as written.
269
+ function checkedIndexes(shown, indexes) {
270
+ const seen = new Set();
271
+ indexes.forEach((entry, at) => {
272
+ const where = `${shown}: indexes[${at}]`;
273
+ if (typeof entry === 'string') {
274
+ if (!isFieldPath(entry)) throw new CliError(`${where}: field must be the name of a field of the collection or a dotted path into one (no empty segment)`, { code: ErrorCode.INVALID_ARGUMENT });
275
+ if (seen.has(`index:${entry}`)) throw new CliError(`${shown}: the index on "${entry}" is declared twice`, { code: ErrorCode.INVALID_ARGUMENT });
276
+ seen.add(`index:${entry}`);
277
+ return;
278
+ }
279
+ if (!isObject(entry)) throw new CliError(`${where} must be a field name or an object {field, unique}`, { code: ErrorCode.INVALID_ARGUMENT });
280
+ if (Object.hasOwn(entry, IndexKey.COMPOUND)) {
281
+ const compound = checkedCompoundEntry(where, entry);
282
+ const key = `compound:${compound.join(',')}`;
283
+ if (seen.has(key)) throw new CliError(`${shown}: the compound index on "${compound.join(', ')}" is declared twice`, { code: ErrorCode.INVALID_ARGUMENT });
284
+ seen.add(key);
285
+ return;
286
+ }
287
+ if (Object.hasOwn(entry, IndexKey.ELEMENTS)) {
288
+ checkedElementsEntry(where, entry);
289
+ const key = `elements:${entry.field}`;
290
+ if (seen.has(key)) throw new CliError(`${shown}: the elements index on "${entry.field}" is declared twice`, { code: ErrorCode.INVALID_ARGUMENT });
291
+ seen.add(key);
292
+ return;
293
+ }
294
+ if (Object.hasOwn(entry, IndexKey.FIELDS)) {
295
+ const fields = checkedFieldsEntry(where, entry);
296
+ const key = `unique:${[...fields].sort().join(',')}`;
297
+ if (seen.has(key)) throw new CliError(`${shown}: the unique index on "${[...fields].sort().join(', ')}" is declared twice`, { code: ErrorCode.INVALID_ARGUMENT });
298
+ seen.add(key);
299
+ return;
300
+ }
301
+ const unknown = Object.keys(entry).find((key) => !INDEX_KEYS.has(key));
302
+ if (unknown !== undefined) {
303
+ throw new CliError(`${where} has unknown key "${unknown}"; an index takes ${[...INDEX_KEYS].join(', ')}`, { code: ErrorCode.INVALID_ARGUMENT });
304
+ }
305
+ const field = entry[IndexKey.FIELD];
306
+ if (!isFieldPath(field)) {
307
+ throw new CliError(`${where}: field must be the name of a field of the collection or a dotted path into one (no empty segment)`, { code: ErrorCode.INVALID_ARGUMENT });
308
+ }
309
+ const unique = entry[IndexKey.UNIQUE];
310
+ if (unique !== undefined && typeof unique !== 'boolean') {
311
+ throw new CliError(`${where}: unique must be true or false`, { code: ErrorCode.INVALID_ARGUMENT });
312
+ }
313
+ const keyword = entry[IndexKey.KEYWORD];
314
+ if (keyword !== undefined && typeof keyword !== 'boolean') {
315
+ throw new CliError(`${where}: keyword must be true or false`, { code: ErrorCode.INVALID_ARGUMENT });
316
+ }
317
+ const termBytes = entry[IndexKey.KEYWORD_TERM_BYTES];
318
+ if (termBytes !== undefined && (keyword !== true || !Number.isInteger(termBytes) || termBytes < 1)) {
319
+ throw new CliError(`${where}: keyword_term_bytes is a whole number of bytes from 1, and only with keyword: true`, { code: ErrorCode.INVALID_ARGUMENT });
320
+ }
321
+ if (keyword === true && unique === true) {
322
+ throw new CliError(`${where}: keyword and unique are separate declarations of a field; list the field twice`, { code: ErrorCode.INVALID_ARGUMENT });
323
+ }
324
+ // A field may be declared once as an index (plain or unique) and once as a keyword field.
325
+ const declared = keyword === true ? `keyword:${field}` : `index:${field}`;
326
+ if (seen.has(declared)) throw new CliError(`${shown}: the ${keyword === true ? 'keyword ' : ''}index on "${field}" is declared twice`, { code: ErrorCode.INVALID_ARGUMENT });
327
+ seen.add(declared);
328
+ });
329
+ return indexes;
330
+ }
331
+
332
+ /** The `index.create` input of the declared `expire` (pure), under the server's own kind and names. */
333
+ export const expireCreateInput = (collection, expire) => ({
334
+ kind: 'expiry', collection, field: expire.field, after_ms: expire.after_ms,
335
+ ...(expire.existing ? { existing: expire.existing } : {}),
336
+ if_not_exists: true,
337
+ });
338
+
339
+ // A collection file's `expire`, checked: {field, after_ms, existing?}, one per collection. Returns it as
340
+ // {field, after_ms, existing} (existing: null when absent).
341
+ function checkedExpire(shown, expire) {
342
+ const where = `${shown}: expire`;
343
+ const unknown = Object.keys(expire).find((key) => !EXPIRE_KEYS.has(key));
344
+ if (unknown !== undefined) throw new CliError(`${where} has unknown key "${unknown}"; expire takes field, after_ms, existing`, { code: ErrorCode.INVALID_ARGUMENT });
345
+ const field = expire[ExpireKey.FIELD];
346
+ if (!isFieldPath(field)) {
347
+ throw new CliError(`${where}: field must be the name of a field of the collection or a dotted path into one (no empty segment)`, { code: ErrorCode.INVALID_ARGUMENT });
348
+ }
349
+ const afterMs = expire[ExpireKey.AFTER_MS];
350
+ if (!Number.isInteger(afterMs) || afterMs < 0) {
351
+ throw new CliError(`${where}: after_ms must be a whole number of milliseconds from 0`, { code: ErrorCode.INVALID_ARGUMENT });
352
+ }
353
+ const existing = expire[ExpireKey.EXISTING];
354
+ if (existing !== undefined && existing !== 'refuse' && existing !== 'allow') {
355
+ throw new CliError(`${where}: existing must be refuse or allow`, { code: ErrorCode.INVALID_ARGUMENT });
356
+ }
357
+ return { field, after_ms: afterMs, existing: existing ?? null };
358
+ }
359
+
360
+ // A collection file's `id`, checked: {derive}, derive a non-empty string of at most 64 bytes.
361
+ // Returns it as {derive}.
362
+ function checkedId(shown, id) {
363
+ const where = `${shown}: id`;
364
+ const unknown = Object.keys(id).find((key) => !ID_KEYS.has(key));
365
+ if (unknown !== undefined) throw new CliError(`${where} has unknown key "${unknown}"; id takes derive`, { code: ErrorCode.INVALID_ARGUMENT });
366
+ const derive = id[IdKey.DERIVE];
367
+ if (typeof derive !== 'string' || derive === '' || Buffer.byteLength(derive, 'utf8') > ID_DERIVE_MAX_BYTES) {
368
+ throw new CliError(`${where}: derive must be a non-empty string of at most ${ID_DERIVE_MAX_BYTES} bytes`, { code: ErrorCode.INVALID_ARGUMENT });
369
+ }
370
+ return { derive };
371
+ }
372
+
373
+ /** The `index.create` input of the declared `id.derive` (pure), under the server's own kind and names. */
374
+ export const idCreateInput = (collection, id) => ({ kind: 'id_derive', collection, derive: id.derive });
375
+
376
+ /** The `index.create` input of one declared aggregate entry (pure). */
377
+ export const aggregateCreateInput = (collection, entry) => ({
378
+ kind: 'aggregate', collection, name: entry.name,
379
+ ...(entry.group.length === 0 ? {} : { group: entry.group }),
380
+ metrics: entry.metrics,
381
+ ...(entry.where === null ? {} : { where: entry.where }),
382
+ });
383
+
384
+ // One entry of `group`, checked: a field path, or {field, step?}. Returns it as read.AggregateGroupPartText takes.
385
+ function checkedGroupPart(where, entry) {
386
+ if (typeof entry === 'string') {
387
+ if (!isFieldPath(entry)) throw new CliError(`${where}: group entries must be a field path or {field, step}`, { code: ErrorCode.INVALID_ARGUMENT });
388
+ return entry;
389
+ }
390
+ if (!isObject(entry) || !isFieldPath(entry.field)) {
391
+ throw new CliError(`${where}: group entries must be a field path or {field, step}`, { code: ErrorCode.INVALID_ARGUMENT });
392
+ }
393
+ const unknown = Object.keys(entry).find((key) => key !== 'field' && key !== 'step');
394
+ if (unknown !== undefined) throw new CliError(`${where} has unknown key "${unknown}"; a group entry takes field, step`, { code: ErrorCode.INVALID_ARGUMENT });
395
+ if (entry.step !== undefined && typeof entry.step !== 'number') {
396
+ throw new CliError(`${where}: step must be a number`, { code: ErrorCode.INVALID_ARGUMENT });
397
+ }
398
+ return entry.step === undefined ? { field: entry.field } : { field: entry.field, step: entry.step };
399
+ }
400
+
401
+ // `metrics`, checked: {name: "count" | {sum: field}, ...}, at least one. Returns it as read.
402
+ function checkedMetrics(where, metrics) {
403
+ if (!isObject(metrics)) throw new CliError(`${where}: metrics must be an object {name: "count" or {sum: field}, ...}`, { code: ErrorCode.INVALID_ARGUMENT });
404
+ const names = Object.keys(metrics);
405
+ if (names.length === 0) throw new CliError(`${where}: metrics names at least one metric`, { code: ErrorCode.INVALID_ARGUMENT });
406
+ for (const name of names) {
407
+ const value = metrics[name];
408
+ if (value === 'count') continue;
409
+ if (isObject(value) && Object.keys(value).length === 1 && isFieldPath(value.sum)) continue;
410
+ throw new CliError(`${where}: metrics.${name} must be "count" or {sum: field}`, { code: ErrorCode.INVALID_ARGUMENT });
411
+ }
412
+ return metrics;
413
+ }
414
+
415
+ // The `aggregates` of a collection file, checked entry by entry: {name, group?, metrics, where?}; each name once.
416
+ // Returns them as {name, group, metrics, where} (group: [], where: null when absent).
417
+ function checkedAggregates(shown, aggregates) {
418
+ const seen = new Set();
419
+ return aggregates.map((entry, at) => {
420
+ const where = `${shown}: aggregates[${at}]`;
421
+ if (!isObject(entry)) throw new CliError(`${where} must be an object {name, group, metrics, where}`, { code: ErrorCode.INVALID_ARGUMENT });
422
+ const unknown = Object.keys(entry).find((key) => !AGGREGATE_KEYS.has(key));
423
+ if (unknown !== undefined) {
424
+ throw new CliError(`${where} has unknown key "${unknown}"; an aggregate takes ${[...AGGREGATE_KEYS].join(', ')}`, { code: ErrorCode.INVALID_ARGUMENT });
425
+ }
426
+ const name = entry[AggregateKey.NAME];
427
+ if (typeof name !== 'string' || name === '') throw new CliError(`${where}: name must be a non-empty string`, { code: ErrorCode.INVALID_ARGUMENT });
428
+ if (seen.has(name)) throw new CliError(`${shown}: the aggregate "${name}" is declared twice`, { code: ErrorCode.INVALID_ARGUMENT });
429
+ seen.add(name);
430
+ const group = entry[AggregateKey.GROUP];
431
+ if (group !== undefined && !Array.isArray(group)) throw new CliError(`${where}: group must be an array`, { code: ErrorCode.INVALID_ARGUMENT });
432
+ const metrics = checkedMetrics(where, entry[AggregateKey.METRICS]);
433
+ const whereClause = entry[AggregateKey.WHERE];
434
+ if (whereClause !== undefined && (typeof whereClause !== 'string' || whereClause === '')) {
435
+ throw new CliError(`${where}: where must be a non-empty string`, { code: ErrorCode.INVALID_ARGUMENT });
436
+ }
437
+ return {
438
+ name,
439
+ group: (group ?? []).map((part) => checkedGroupPart(where, part)),
440
+ metrics,
441
+ where: whereClause ?? null,
442
+ };
443
+ });
444
+ }
445
+
446
+ // One collection file, checked: {name, file, schema, rules, indexes, aggregates, id} (an absent key is null).
447
+ function parseCollection(config, cwd, fileName) {
448
+ const shown = `${collectionsDirOf(config)}/${fileName}`;
449
+ const name = basename(fileName, '.json');
450
+ const body = readJsonFile(join(resolve(cwd, collectionsDirOf(config)), fileName), 'collection file', shown);
451
+ if (!isObject(body)) throw new CliError(`${shown} must be a JSON object with schema, rules and indexes`, { code: ErrorCode.INVALID_ARGUMENT });
452
+ const unknown = Object.keys(body).filter((key) => !KEYS.has(key));
453
+ if (unknown.length) {
454
+ throw new CliError(`${shown} has unknown key "${unknown[0]}"; a collection file takes ${[...KEYS].join(', ')}`, { code: ErrorCode.INVALID_ARGUMENT });
455
+ }
456
+ const checked = (key, ok, what) => {
457
+ const value = body[key];
458
+ if (value === undefined || value === null) return null;
459
+ if (!ok(value)) throw new CliError(`${shown}: ${key} must be ${what}`, { code: ErrorCode.INVALID_ARGUMENT });
460
+ return value;
461
+ };
462
+ const rules = checked(CollectionKey.RULES, isObject, 'an object (read, write, create, delete)');
463
+ if (rules !== null && Object.hasOwn(rules, 'collection')) {
464
+ throw new CliError(`${shown}: rules must not name a collection; the file's name is the collection`, { code: ErrorCode.INVALID_ARGUMENT });
465
+ }
466
+ return {
467
+ name, file: shown,
468
+ schema: checked(CollectionKey.SCHEMA, isObject, 'an object (rules, strict)'),
469
+ rules,
470
+ indexes: ((indexes) => (indexes === null ? null : checkedIndexes(shown, indexes)))(checked(CollectionKey.INDEXES, Array.isArray, 'an array')),
471
+ expire: ((expire) => (expire === null ? null : checkedExpire(shown, expire)))(checked(CollectionKey.EXPIRE, isObject, 'an object (field, after_ms, existing)')),
472
+ aggregates: ((aggregates) => (aggregates === null ? null : checkedAggregates(shown, aggregates)))(checked(CollectionKey.AGGREGATES, Array.isArray, 'an array')),
473
+ id: ((id) => (id === null ? null : checkedId(shown, id)))(checked(CollectionKey.ID, isObject, 'an object (derive)')),
474
+ };
475
+ }
476
+
477
+ /**
478
+ * The collections the project declares, by name: [{name, file, schema, rules, indexes, aggregates, id}].
479
+ * None when the directory is not there. Throws a CliError for a file that is not
480
+ * valid, naming it.
481
+ */
482
+ export function declaredCollections(config, cwd) {
483
+ const dir = resolve(cwd, collectionsDirOf(config));
484
+ if (!existsSync(dir)) return [];
485
+ let names;
486
+ try {
487
+ names = readdirSync(dir).filter((f) => extname(f) === '.json').sort();
488
+ } catch (err) {
489
+ throw new CliError(`${collectionsDirOf(config)}: ${err.message}`, { code: ErrorCode.INVALID_ARGUMENT });
490
+ }
491
+ return names.map((fileName) => parseCollection(config, cwd, fileName));
492
+ }
493
+
494
+ /**
495
+ * The app's security rules, from rules_file and the collections' own rules:
496
+ * null when there are none, else {body (the security.rules.set input), sources
497
+ * (the files they came from), merged (more than rules_file is in the body)}.
498
+ * Throws a CliError naming both files when a collection has rules in both.
499
+ */
500
+ export function declaredRules(config, cwd, collections) {
501
+ const base = config.rules_file ? readJsonFile(resolve(cwd, config.rules_file), 'rules_file', config.rules_file) : null;
502
+ const own = collections.filter((c) => c.rules !== null);
503
+ if (own.length === 0) return base === null ? null : { body: base, sources: [config.rules_file], merged: false };
504
+ if (base !== null && !isObject(base)) throw new CliError(`rules_file ${config.rules_file} must be a JSON object with a rules array`, { code: ErrorCode.INVALID_ARGUMENT });
505
+ const entries = base?.rules ?? [];
506
+ if (!Array.isArray(entries)) throw new CliError(`rules_file ${config.rules_file}: rules must be an array`, { code: ErrorCode.INVALID_ARGUMENT });
507
+ for (const collection of own) {
508
+ if (entries.some((entry) => entry?.collection === collection.name)) {
509
+ throw new CliError(`rules for collection "${collection.name}" are defined in both ${config.rules_file} and ${collection.file}; keep them in one`,
510
+ { code: ErrorCode.INVALID_ARGUMENT });
511
+ }
512
+ }
513
+ return {
514
+ body: { ...(base ?? {}), rules: [...entries, ...own.map((c) => ({ collection: c.name, ...c.rules }))] },
515
+ sources: [...(config.rules_file ? [config.rules_file] : []), ...own.map((c) => c.file)],
516
+ merged: true,
517
+ };
518
+ }
519
+
520
+ /** The rules as a blob of a version, made in memory (like a precompressed sidecar): {path, data, size, hash}. */
521
+ export function rulesBlob(rules) {
522
+ const data = Buffer.from(JSON.stringify(rules.body));
523
+ return { path: rules.sources.join(' + '), data, size: data.length, hash: sha256(data) };
524
+ }
525
+
526
+ /** " (2 indexes)" for a collection's line in deploy's output; nothing for none. */
527
+ export const indexCountLabel = (count) => (count > 0 ? ` (${count} index${count === 1 ? '' : 'es'})` : '');
528
+
529
+ /** " (2 aggregates)" for a collection's line in deploy's output; nothing for none. */
530
+ export const aggregateCountLabel = (count) => (count > 0 ? ` (${count} aggregate${count === 1 ? '' : 's'})` : '');
531
+
532
+ /** " (expiry)" for a collection's line in deploy's output when it declares one; nothing otherwise. */
533
+ export const expireLabel = (declared) => (declared ? ' (expiry)' : '');
534
+
535
+ /**
536
+ * The calls that bring the server to the declared collections (pure): per collection
537
+ * `collection.create` (a collection that exists is not an error: see applyCollections),
538
+ * when it declares `id.derive` one `index.create` of kind id_derive (UM-3d; idempotent for
539
+ * its own value), when it declares a schema `schema.update`, per declared index one
540
+ * `index.create` of kind collection (idempotent: if_not_exists), then when it declares
541
+ * `expire` one `index.create` of kind expiry (idempotent: if_not_exists), then per declared
542
+ * aggregate one `index.create` of kind aggregate (idempotent on its own: a redeclaration of
543
+ * the same name and shape changes nothing), in file order.
544
+ */
545
+ export function collectionSteps(collections) {
546
+ return collections.flatMap((c) => [
547
+ { collection: c.name, operation: 'collection.create', input: { name: c.name }, existsIsFine: true },
548
+ ...(c.id ? [{ collection: c.name, operation: 'index.create', existsIsFine: false, input: idCreateInput(c.name, c.id) }] : []),
549
+ ...(c.schema === null ? [] : [{ collection: c.name, operation: 'schema.update', input: { collection: c.name, ...c.schema }, existsIsFine: false }]),
550
+ ...(c.indexes ?? []).map(indexEntry).map((entry) => ({
551
+ collection: c.name, operation: 'index.create', existsIsFine: false,
552
+ input: indexCreateInput(c.name, entry),
553
+ })),
554
+ ...(c.expire ? [{ collection: c.name, operation: 'index.create', existsIsFine: false, input: expireCreateInput(c.name, c.expire) }] : []),
555
+ ...(c.aggregates ?? []).map((entry) => ({
556
+ collection: c.name, operation: 'index.create', existsIsFine: false,
557
+ input: aggregateCreateInput(c.name, entry),
558
+ })),
559
+ ]);
560
+ }
561
+
562
+
563
+ /**
564
+ * Applies `collections` with `invoke(name, input)` (throws a CliError on a failed
565
+ * call): the collection made when missing, its id.derive, schema, indexes, expire and
566
+ * aggregates declared. Idempotent. `done` receives what was applied as it goes, so a
567
+ * failure keeps its progress. Returns {applied: [{collection, created, id, schema, indexes,
568
+ * expire, aggregates}], warnings} (indexes, aggregates: how many of each were declared;
569
+ * id, expire: whether each was declared; warnings: none, every declaration is applied).
570
+ */
571
+ export async function applyCollections(invoke, collections, done = []) {
572
+ for (const c of collections) {
573
+ const entry = { collection: c.name, created: true, id: false, schema: false, indexes: 0, aggregates: 0, expire: false };
574
+ for (const step of collectionSteps([c])) {
575
+ try {
576
+ await invoke(step.operation, step.input);
577
+ } catch (err) {
578
+ if (!(step.existsIsFine && err instanceof CliError && err.status === EXISTS)) throw err;
579
+ entry.created = false;
580
+ }
581
+ if (step.operation === 'schema.update') entry.schema = true;
582
+ if (step.operation === 'index.create' && step.input.kind === 'collection') entry.indexes += 1;
583
+ if (step.operation === 'index.create' && step.input.kind === 'id_derive') entry.id = true;
584
+ if (step.operation === 'index.create' && step.input.kind === 'expiry') entry.expire = true;
585
+ if (step.operation === 'index.create' && step.input.kind === 'aggregate') entry.aggregates += 1;
586
+ }
587
+ done.push(entry);
588
+ }
589
+ return { applied: done, warnings: [] };
590
+ }
591
+
592
+ /**
593
+ * The names of the collections the server has, every page of collection.list read with `invoke(name, input)`
594
+ * (throws a CliError on a failed call): a Set.
595
+ */
596
+ export async function serverCollectionNames(invoke) {
597
+ const names = new Set();
598
+ let cursor;
599
+ for (;;) {
600
+ const page = await invoke('collection.list', cursor === undefined ? {} : { [PAGE_CURSOR_INPUT]: cursor });
601
+ for (const entry of page?.collections ?? []) names.add(entry.name);
602
+ const next = page?.[PAGE_NEXT_CURSOR_FIELD];
603
+ if (typeof next !== 'string' || next === '') return names;
604
+ if (next === cursor) throw new CliError(`collection.list answered the cursor it was given (${next}) as its next one`, { code: ErrorCode.UNKNOWN });
605
+ cursor = next;
606
+ }
607
+ }
608
+
609
+ /** The declared `collections` the server does not have, by name (a collection deleted on the server): [name]. */
610
+ export async function missingCollections(invoke, collections) {
611
+ if (collections.length === 0) return [];
612
+ const present = await serverCollectionNames(invoke);
613
+ return collections.map((c) => c.name).filter((name) => !present.has(name));
614
+ }
615
+
616
+ /**
617
+ * The version member for `collections` (pure): [{name, id?, schema?, indexes?, expire?, aggregates?}]. The
618
+ * rules go with the version's rules, so they are not here.
619
+ */
620
+ export function collectionsMember(collections) {
621
+ return collections.map((c) => ({
622
+ name: c.name,
623
+ ...(c.id ? { id: c.id } : {}),
624
+ ...(c.schema === null ? {} : { schema: c.schema }),
625
+ ...(c.indexes === null ? {} : { indexes: c.indexes }),
626
+ ...(c.expire ? { expire: c.expire } : {}),
627
+ ...(c.aggregates === null ? {} : { aggregates: c.aggregates }),
628
+ }));
629
+ }
630
+
631
+ /**
632
+ * The version member for `collections` (VERSION-COLLECTIONS): the calls substrio deploy makes
633
+ * (collectionSteps), which the server runs in order on the target when the version goes live, as
634
+ * [{operation, collection?, body}]. Each input is split by the route the call would take, as
635
+ * callOperation splits it: schema.update's collection is its route's, named apart; the rest is the body.
636
+ */
637
+ export function versionCollectionSteps(ops, collections) {
638
+ return collectionSteps(collections).map(({ operation, input }) => {
639
+ const route = pickRoute(findOperation(ops, operation), input);
640
+ const { body } = buildRequest(route, input);
641
+ return route.params.includes('collection') ? { operation, collection: input.collection, body } : { operation, body };
642
+ });
643
+ }
644
+
645
+ /**
646
+ * Checks that the server's version.create takes the collections member (its input
647
+ * schema in the indexed operations `ops` declares it). Throws a CliError saying
648
+ * so when it does not: a version that silently left them out would put an app live
649
+ * without its collections.
650
+ */
651
+ export function checkVersionCarriesCollections(ops) {
652
+ const schema = findOperation(ops, 'version.create').find((r) => r.bodySchema)?.bodySchema;
653
+ if (Object.hasOwn(schema?.properties ?? {}, COLLECTIONS_MEMBER)) return;
654
+ throw new CliError(`this server's versions cannot carry the declared collections yet (version.create has no "${COLLECTIONS_MEMBER}" member)`, {
655
+ code: ErrorCode.INVALID_ARGUMENT,
656
+ hint: 'substrio deploy applies them to the app directly; substrio dev applies them to the local server',
657
+ });
658
+ }
659
+
660
+ /**
661
+ * The server's declarations for `collections` that the file no longer names: an index of kind collection
662
+ * (plain, unique, keyword, elements or compound), an aggregate, or an expiry. Read with `invoke(name, input)`
663
+ * (index.list, kind collection then aggregate then expiry, once per collection; throws a CliError on a failed
664
+ * call). Returns [{collection, kind ('index', 'aggregate' or 'expiry'), label, drop}] (drop: the index.drop
665
+ * input pruneDrift sends). A declaration the file still names, in the same shape, is matched and never
666
+ * reported (fileIndexIdentity, serverIndexIdentity); a field's undeclared adaptive key is not an answer of
667
+ * index.list, so it is never reported or pruned.
668
+ */
669
+ export async function collectionDrift(invoke, collections) {
670
+ const drift = [];
671
+ for (const c of collections) {
672
+ const fileIndexIds = new Set((c.indexes ?? []).map(indexEntry).map(fileIndexIdentity));
673
+ const indexPage = await invoke('index.list', { kind: 'collection', collection: c.name });
674
+ for (const entry of indexPage?.indexes ?? []) {
675
+ if (fileIndexIds.has(serverIndexIdentity(entry))) continue;
676
+ drift.push({ collection: c.name, kind: 'index', label: serverIndexLabel(entry), drop: serverIndexDropInput(c.name, entry) });
677
+ }
678
+ const fileAggNames = new Set((c.aggregates ?? []).map((a) => a.name));
679
+ const aggPage = await invoke('index.list', { kind: 'aggregate', collection: c.name });
680
+ for (const entry of aggPage?.indexes ?? []) {
681
+ if (fileAggNames.has(entry.name)) continue;
682
+ drift.push({ collection: c.name, kind: 'aggregate', label: entry.name, drop: { kind: 'aggregate', collection: c.name, name: entry.name } });
683
+ }
684
+ if (!c.expire) {
685
+ const expiry = await invoke('index.list', { kind: 'expiry', collection: c.name });
686
+ if (expiry?.declared) {
687
+ drift.push({ collection: c.name, kind: 'expiry', label: expiry.field, drop: { kind: 'expiry', collection: c.name, name: 'expiry' } });
688
+ }
689
+ }
690
+ }
691
+ return drift;
692
+ }
693
+
694
+ /**
695
+ * Drops every entry of `drift` with `invoke(name, input)` (index.drop; throws a CliError on a failed call, the
696
+ * run stops there, as applyCollections does). Returns `drift`, for the caller to report what was pruned.
697
+ */
698
+ export async function pruneDrift(invoke, drift) {
699
+ for (const d of drift) await invoke('index.drop', d.drop);
700
+ return drift;
701
+ }