@substrio/cli 0.0.0-stage → 0.1.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/LICENSE +177 -0
- package/README.md +66 -2
- package/bin/substrio.mjs +91 -0
- package/package.json +44 -4
- package/src/api.mjs +107 -0
- package/src/apply.mjs +204 -0
- package/src/blobs.mjs +102 -0
- package/src/cli.mjs +405 -0
- package/src/collections.mjs +701 -0
- package/src/command-table.mjs +436 -0
- package/src/commands/bundle.mjs +369 -0
- package/src/commands/count.mjs +39 -0
- package/src/commands/deploy.mjs +488 -0
- package/src/commands/dev.mjs +246 -0
- package/src/commands/env.mjs +115 -0
- package/src/commands/files.mjs +172 -0
- package/src/commands/git.mjs +195 -0
- package/src/commands/groups-runtime.mjs +227 -0
- package/src/commands/groups.mjs +417 -0
- package/src/commands/history.mjs +374 -0
- package/src/commands/import.mjs +506 -0
- package/src/commands/init.mjs +175 -0
- package/src/commands/key.mjs +172 -0
- package/src/commands/login.mjs +167 -0
- package/src/commands/logs.mjs +285 -0
- package/src/commands/mcp-config.mjs +102 -0
- package/src/commands/op.mjs +265 -0
- package/src/commands/open.mjs +35 -0
- package/src/commands/push.mjs +199 -0
- package/src/commands/signup.mjs +164 -0
- package/src/commands/site.mjs +129 -0
- package/src/commands/skills.mjs +177 -0
- package/src/commands/status.mjs +78 -0
- package/src/commands/tenants.mjs +172 -0
- package/src/commands/test.mjs +167 -0
- package/src/commands/tools.mjs +63 -0
- package/src/commands/versions.mjs +293 -0
- package/src/config.mjs +569 -0
- package/src/documents.mjs +139 -0
- package/src/function-build.mjs +273 -0
- package/src/identity.mjs +248 -0
- package/src/jsonfile.mjs +22 -0
- package/src/live-move.mjs +267 -0
- package/src/local/args.mjs +58 -0
- package/src/local/reload.mjs +408 -0
- package/src/local/server.mjs +291 -0
- package/src/local/watch.mjs +175 -0
- package/src/manifest.mjs +591 -0
- package/src/ops.mjs +265 -0
- package/src/readers.mjs +416 -0
- package/src/sdk.mjs +26 -0
- package/src/settings.mjs +117 -0
- package/src/testing/app.mjs +507 -0
- package/src/testing/env.mjs +35 -0
- package/src/testing/index.mjs +11 -0
- package/src/testing/types.mjs +55 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Substrio CLI: move an app between servers as one bundle.
|
|
3
|
+
*
|
|
4
|
+
* substrio pull (--for PROFILE | --seal-to PUBKEY) [--out FILE]
|
|
5
|
+
* Stream the app's export to FILE, sealed whole to one server's key
|
|
6
|
+
* (--for: that profile's server, or --seal-to): only that server can
|
|
7
|
+
* read any of it, so one of the two is required.
|
|
8
|
+
* substrio push-bundle FILE --to PROFILE [--slug S] [--replace] [--save-profile NAME]
|
|
9
|
+
* Stream FILE into the server of PROFILE as an app.
|
|
10
|
+
* substrio move --to PROFILE [--slug S] [--replace] [--save-profile NAME]
|
|
11
|
+
* Pipe the app's export, sealed to PROFILE's server, straight into that
|
|
12
|
+
* server's import: one stream, no temp file.
|
|
13
|
+
*
|
|
14
|
+
* The destination takes its system key from the saved profile, never from the
|
|
15
|
+
* command line. The new app's key is saved (0600) to profile NAME only with
|
|
16
|
+
* --save-profile, and is never printed.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { createHash } from 'node:crypto';
|
|
20
|
+
import {
|
|
21
|
+
createReadStream,
|
|
22
|
+
createWriteStream,
|
|
23
|
+
statSync,
|
|
24
|
+
unlinkSync,
|
|
25
|
+
} from 'node:fs';
|
|
26
|
+
import { Readable, Transform } from 'node:stream';
|
|
27
|
+
import { pipeline } from 'node:stream/promises';
|
|
28
|
+
|
|
29
|
+
import { SubstrioAPI } from '../api.mjs';
|
|
30
|
+
import { addressOf, failover, liveMove, LiveMoveError } from '../live-move.mjs';
|
|
31
|
+
import { listProfiles, readProfile, writeProfile } from '../config.mjs';
|
|
32
|
+
import { ErrorCode, fail } from '../cli.mjs';
|
|
33
|
+
import { invokeOperation } from './op.mjs';
|
|
34
|
+
|
|
35
|
+
const C = {
|
|
36
|
+
reset: '\x1b[0m',
|
|
37
|
+
bold: '\x1b[1m',
|
|
38
|
+
dim: '\x1b[2m',
|
|
39
|
+
red: '\x1b[91m',
|
|
40
|
+
green: '\x1b[92m',
|
|
41
|
+
yellow: '\x1b[93m',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const BUNDLE_TYPE = 'application/vnd.substrio.bundle';
|
|
45
|
+
|
|
46
|
+
const USAGE = {
|
|
47
|
+
pull: 'substrio pull (--for PROFILE | --seal-to PUBKEY) [--out FILE]',
|
|
48
|
+
'push-bundle':
|
|
49
|
+
'substrio push-bundle FILE --to PROFILE [--slug S] [--replace] [--save-profile NAME]',
|
|
50
|
+
move: 'substrio move --to PROFILE [--slug S] [--replace] [--save-profile NAME] | substrio move --live --to PROFILE | substrio move --failover --to PROFILE',
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// The flags each command takes: true for one with a value, false for a switch.
|
|
54
|
+
const FLAGS = {
|
|
55
|
+
pull: { '--out': true, '--seal-to': true, '--for': true },
|
|
56
|
+
'push-bundle': {
|
|
57
|
+
'--to': true,
|
|
58
|
+
'--slug': true,
|
|
59
|
+
'--replace': false,
|
|
60
|
+
'--save-profile': true,
|
|
61
|
+
},
|
|
62
|
+
move: {
|
|
63
|
+
'--to': true,
|
|
64
|
+
'--slug': true,
|
|
65
|
+
'--replace': false,
|
|
66
|
+
'--save-profile': true,
|
|
67
|
+
'--live': false,
|
|
68
|
+
'--failover': false,
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The options of `command` from its arguments: {out, sealTo, for, to, slug,
|
|
75
|
+
* replace, saveProfile, file}. Throws on an unknown flag, a missing value, or
|
|
76
|
+
* a combination the command does not take. Pure.
|
|
77
|
+
*/
|
|
78
|
+
export function parseBundleArgs(command, args) {
|
|
79
|
+
const known = FLAGS[command];
|
|
80
|
+
if (!known) throw new Error(`Unknown bundle command: ${command}`);
|
|
81
|
+
const opts = { replace: false };
|
|
82
|
+
const positional = [];
|
|
83
|
+
for (let i = 0; i < args.length; i++) {
|
|
84
|
+
const arg = args[i];
|
|
85
|
+
if (!arg.startsWith('--')) {
|
|
86
|
+
positional.push(arg);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (!(arg in known))
|
|
90
|
+
throw new Error(`${command} takes no ${arg}. Usage: ${USAGE[command]}`);
|
|
91
|
+
const name = arg.slice(2).replace(/-(\w)/g, (_, c) => c.toUpperCase());
|
|
92
|
+
if (!known[arg]) {
|
|
93
|
+
opts[name] = true;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const value = args[++i];
|
|
97
|
+
if (value === undefined || value.startsWith('--'))
|
|
98
|
+
throw new Error(`${arg} needs a value`);
|
|
99
|
+
opts[name] = value;
|
|
100
|
+
}
|
|
101
|
+
if (command === 'push-bundle') {
|
|
102
|
+
if (positional.length !== 1)
|
|
103
|
+
throw new Error(`Name one bundle file. Usage: ${USAGE[command]}`);
|
|
104
|
+
opts.file = positional[0];
|
|
105
|
+
} else if (positional.length) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
`Unexpected argument ${positional[0]}. Usage: ${USAGE[command]}`
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
if (command === 'move' && opts.live && opts.failover)
|
|
111
|
+
throw new Error('Pass --live or --failover, not both');
|
|
112
|
+
if (command === 'move' && (opts.live || opts.failover) && (opts.slug || opts.replace || opts.saveProfile))
|
|
113
|
+
throw new Error(`A live move keeps the app's slug and its keys: --${opts.live ? 'live' : 'failover'} takes --to only`);
|
|
114
|
+
if (command === 'pull' && opts.sealTo && opts.for)
|
|
115
|
+
throw new Error('Pass --seal-to or --for, not both');
|
|
116
|
+
if (command === 'pull' && !opts.sealTo && !opts.for)
|
|
117
|
+
throw new Error(
|
|
118
|
+
`A bundle is sealed to the server that imports it: pass --for PROFILE or --seal-to PUBKEY. Usage: ${USAGE[command]}`
|
|
119
|
+
);
|
|
120
|
+
if (command !== 'pull' && !opts.to)
|
|
121
|
+
throw new Error(`--to PROFILE is required. Usage: ${USAGE[command]}`);
|
|
122
|
+
return opts;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// A saved profile with a URL and a key, or a failure naming what is missing.
|
|
126
|
+
function destination(name) {
|
|
127
|
+
const profile = readProfile(name);
|
|
128
|
+
if (!profile)
|
|
129
|
+
fail(
|
|
130
|
+
`No saved profile "${name}"`,
|
|
131
|
+
'Save one with: substrio login --profile NAME'
|
|
132
|
+
);
|
|
133
|
+
if (!profile.url || !profile.master_key)
|
|
134
|
+
fail(`Profile "${name}" needs a url and a key`);
|
|
135
|
+
return profile;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// The API of a destination: its system key, and no app named.
|
|
139
|
+
const destinationApi = (profile) =>
|
|
140
|
+
new SubstrioAPI({ url: profile.url, master_key: profile.master_key, tenant: null });
|
|
141
|
+
|
|
142
|
+
// The seal key a server publishes for bundles sent to it (app.seal_key.get).
|
|
143
|
+
async function sealKey(api) {
|
|
144
|
+
const data = await invokeOperation(api, 'app.seal_key.get', {});
|
|
145
|
+
if (!data?.public_key)
|
|
146
|
+
fail(`${api.url} published no seal key`, null, ErrorCode.UNKNOWN);
|
|
147
|
+
return data.public_key;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// The source's export (app.export) as a response whose body is still unread.
|
|
151
|
+
const openExport = (config, sealTo) =>
|
|
152
|
+
invokeOperation(new SubstrioAPI(config), 'app.export', { seal_to: sealTo }, { stream: true });
|
|
153
|
+
|
|
154
|
+
// Streams `body` (a web ReadableStream) into the destination's import
|
|
155
|
+
// (app.import) and returns the parsed result.
|
|
156
|
+
const sendImport = (api, opts, body) =>
|
|
157
|
+
invokeOperation(
|
|
158
|
+
api,
|
|
159
|
+
'app.import',
|
|
160
|
+
{ ...(opts.slug ? { slug: opts.slug } : {}), ...(opts.replace ? { replace: true } : {}) },
|
|
161
|
+
{
|
|
162
|
+
body,
|
|
163
|
+
headers: { 'Content-Type': BUNDLE_TYPE },
|
|
164
|
+
hint: (result) =>
|
|
165
|
+
result.status === 409 ? 'The app has data there; pass --replace to overwrite it.' : null,
|
|
166
|
+
}
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const list = (items) => (items?.length ? items.join(', ') : 'none');
|
|
170
|
+
|
|
171
|
+
// The import result, without its key; the key goes to a profile when asked.
|
|
172
|
+
function report(result, profile, opts) {
|
|
173
|
+
const { key, ...shown } = result;
|
|
174
|
+
const verb = shown.created ? 'created' : 'replaced';
|
|
175
|
+
console.log(
|
|
176
|
+
`${C.green}✓${C.reset} App ${C.bold}${shown.app}${C.reset} ${verb} on ${profile.url}`
|
|
177
|
+
);
|
|
178
|
+
console.log(
|
|
179
|
+
` versions ${shown.versions}, live ${shown.live ?? 'none'}, blobs ${shown.blobs}, database ${shown.database_bytes} bytes`
|
|
180
|
+
);
|
|
181
|
+
console.log(` secrets: ${list(shown.secrets)}`);
|
|
182
|
+
const missing = shown.secrets_missing || [];
|
|
183
|
+
const tint = missing.length ? C.yellow : '';
|
|
184
|
+
console.log(
|
|
185
|
+
`${tint} secrets_missing: ${list(missing)}${tint ? C.reset : ''}`
|
|
186
|
+
);
|
|
187
|
+
console.log(` sandboxes: ${list(shown.sandboxes)}`);
|
|
188
|
+
if (shown.sandboxes_failed?.length)
|
|
189
|
+
console.log(
|
|
190
|
+
`${C.yellow} sandboxes_failed: ${list(shown.sandboxes_failed)}${C.reset}`
|
|
191
|
+
);
|
|
192
|
+
if (opts.saveProfile && key) {
|
|
193
|
+
writeProfile(opts.saveProfile, profile.url, key);
|
|
194
|
+
console.log(
|
|
195
|
+
` The app's key is saved to profile ${C.bold}${opts.saveProfile}${C.reset}.`
|
|
196
|
+
);
|
|
197
|
+
console.log(
|
|
198
|
+
`${C.dim} In the app's substrio.toml: tenant = "${shown.app}", profile = "${opts.saveProfile}".${C.reset}`
|
|
199
|
+
);
|
|
200
|
+
} else if (key) {
|
|
201
|
+
console.log(
|
|
202
|
+
`${C.dim} The app's new key was not saved (pass --save-profile NAME to keep it).${C.reset}`
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// A new profile name must not overwrite a saved one; checked before any byte moves.
|
|
208
|
+
function checkSaveProfile(opts) {
|
|
209
|
+
if (
|
|
210
|
+
opts.saveProfile &&
|
|
211
|
+
listProfiles().some((p) => p.name === opts.saveProfile)
|
|
212
|
+
) {
|
|
213
|
+
fail(
|
|
214
|
+
`A profile named "${opts.saveProfile}" already exists; pass --save-profile with another name.`
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function parseOrFail(command, args) {
|
|
220
|
+
try {
|
|
221
|
+
return parseBundleArgs(command, args);
|
|
222
|
+
} catch (err) {
|
|
223
|
+
fail(err);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** substrio pull: the app's export, streamed to a file with its size and SHA-256. */
|
|
228
|
+
export async function cmdPull(config, args) {
|
|
229
|
+
const opts = parseOrFail('pull', args);
|
|
230
|
+
const sealTo = opts.sealTo || (await sealKey(destinationApi(destination(opts.for))));
|
|
231
|
+
const out = opts.out || `${config.tenant || 'default'}.substrio-bundle`;
|
|
232
|
+
const exported = await openExport(config, sealTo);
|
|
233
|
+
const hash = createHash('sha256');
|
|
234
|
+
let bytes = 0;
|
|
235
|
+
const count = new Transform({
|
|
236
|
+
transform(chunk, _enc, done) {
|
|
237
|
+
hash.update(chunk);
|
|
238
|
+
bytes += chunk.length;
|
|
239
|
+
done(null, chunk);
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
try {
|
|
243
|
+
await pipeline(
|
|
244
|
+
Readable.fromWeb(exported.body),
|
|
245
|
+
count,
|
|
246
|
+
createWriteStream(out, { mode: 0o600 })
|
|
247
|
+
);
|
|
248
|
+
} catch (err) {
|
|
249
|
+
try {
|
|
250
|
+
unlinkSync(out);
|
|
251
|
+
} catch {
|
|
252
|
+
/* nothing was written */
|
|
253
|
+
}
|
|
254
|
+
fail(`Export stream failed: ${err.message}`, null, ErrorCode.UNKNOWN);
|
|
255
|
+
}
|
|
256
|
+
console.log(`${C.green}✓${C.reset} Wrote ${bytes} bytes to ${out}`);
|
|
257
|
+
console.log(` sha256 ${hash.digest('hex')}`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** substrio push-bundle: a bundle file, streamed into the destination's import. */
|
|
261
|
+
export async function cmdPushBundle(args) {
|
|
262
|
+
const opts = parseOrFail('push-bundle', args);
|
|
263
|
+
const profile = destination(opts.to);
|
|
264
|
+
checkSaveProfile(opts);
|
|
265
|
+
try {
|
|
266
|
+
statSync(opts.file);
|
|
267
|
+
} catch {
|
|
268
|
+
fail(`No such file: ${opts.file}`, null, ErrorCode.FILE_NOT_FOUND);
|
|
269
|
+
}
|
|
270
|
+
const result = await sendImport(
|
|
271
|
+
destinationApi(profile),
|
|
272
|
+
opts,
|
|
273
|
+
Readable.toWeb(createReadStream(opts.file))
|
|
274
|
+
);
|
|
275
|
+
report(result, profile, opts);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** substrio move: the export, sealed to the destination, piped into its import. */
|
|
279
|
+
export async function cmdMove(config, args) {
|
|
280
|
+
const opts = parseOrFail('move', args);
|
|
281
|
+
if (opts.live) return cmdLiveMove(config, opts);
|
|
282
|
+
if (opts.failover) return cmdFailover(config, opts);
|
|
283
|
+
const profile = destination(opts.to);
|
|
284
|
+
checkSaveProfile(opts);
|
|
285
|
+
const api = destinationApi(profile);
|
|
286
|
+
const exported = await openExport(config, await sealKey(api));
|
|
287
|
+
const result = await sendImport(api, opts, exported.body);
|
|
288
|
+
report(result, profile, opts);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// One server of a live move, from a profile: its master key on every call, the app named per call.
|
|
292
|
+
function moveServer(url, masterKey) {
|
|
293
|
+
const api = new SubstrioAPI({ url, master_key: masterKey, tenant: null });
|
|
294
|
+
return {
|
|
295
|
+
address: addressOf(url),
|
|
296
|
+
url,
|
|
297
|
+
call: async (method, path, body, tenant) => {
|
|
298
|
+
const res = await api.raw(method, path, body, tenant ? { 'X-Substrio-Tenant': tenant } : {});
|
|
299
|
+
// Read as bytes first (MOVE-SITE forwards a site file through this `call`; decoding it as
|
|
300
|
+
// UTF-8 text before that would corrupt one that is not valid UTF-8).
|
|
301
|
+
const bytes = Buffer.from(await res.arrayBuffer());
|
|
302
|
+
const text = bytes.toString('utf8');
|
|
303
|
+
let json;
|
|
304
|
+
try {
|
|
305
|
+
json = JSON.parse(text);
|
|
306
|
+
} catch {
|
|
307
|
+
json = undefined;
|
|
308
|
+
}
|
|
309
|
+
return { status: res.status, json, text, bytes };
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* substrio move --live: the app moves to PROFILE's server while it keeps taking writes
|
|
316
|
+
* (live-move.mjs). Both profiles hold their server's master key; the slug stays the same.
|
|
317
|
+
*/
|
|
318
|
+
async function cmdLiveMove(config, opts) {
|
|
319
|
+
const profile = destination(opts.to);
|
|
320
|
+
const slug = config.tenant;
|
|
321
|
+
if (!slug) fail('Name the app to move: its profile has no tenant');
|
|
322
|
+
const step = (event, detail) =>
|
|
323
|
+
console.log(
|
|
324
|
+
event === 'flipped'
|
|
325
|
+
? `flipped: ${profile.url} takes writes (fence ${detail.fenceMs.toFixed(1)} ms)`
|
|
326
|
+
: event === 'sessions_end'
|
|
327
|
+
? `sessions_end: ${profile.url} signs sessions with its own key; every session on ${config.url} ends`
|
|
328
|
+
: event,
|
|
329
|
+
);
|
|
330
|
+
let result;
|
|
331
|
+
try {
|
|
332
|
+
result = await liveMove(slug, moveServer(config.url, config.master_key), moveServer(profile.url, profile.master_key), {
|
|
333
|
+
onEvent: step,
|
|
334
|
+
});
|
|
335
|
+
} catch (err) {
|
|
336
|
+
fail(err instanceof LiveMoveError ? err.message : String(err?.message ?? err));
|
|
337
|
+
}
|
|
338
|
+
console.log(`${slug} lives on ${profile.url}; ${config.url} keeps it as a read-only replica`);
|
|
339
|
+
console.log(
|
|
340
|
+
result.site
|
|
341
|
+
? `site: release ${result.site.release}, ${result.site.files} file(s), ${result.site.bytes} byte(s)`
|
|
342
|
+
: 'site: none',
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* substrio move --failover: PROFILE's server, a replica of the app, takes its writes because the
|
|
348
|
+
* current profile's server no longer answers (live-move.mjs failover). A server that does answer
|
|
349
|
+
* is moved live instead, losing nothing.
|
|
350
|
+
*/
|
|
351
|
+
async function cmdFailover(config, opts) {
|
|
352
|
+
const profile = destination(opts.to);
|
|
353
|
+
const slug = config.tenant;
|
|
354
|
+
if (!slug) fail('Name the app to fail over: its profile has no tenant');
|
|
355
|
+
let out;
|
|
356
|
+
try {
|
|
357
|
+
out = await failover(slug, moveServer(config.url, config.master_key), moveServer(profile.url, profile.master_key), {
|
|
358
|
+
onEvent: (event) => console.log(event),
|
|
359
|
+
});
|
|
360
|
+
} catch (err) {
|
|
361
|
+
fail(err instanceof LiveMoveError ? err.message : String(err?.message ?? err));
|
|
362
|
+
}
|
|
363
|
+
if (out.mode === 'move') {
|
|
364
|
+
console.log(`${config.url} answered, so ${slug} was moved live (fence ${out.fenceMs.toFixed(1)} ms): nothing lost`);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
console.log(`${slug} takes writes on ${profile.url}, holding ${config.url}'s writes up to entry ${out.held}`);
|
|
368
|
+
console.log(`${config.url} did not answer and is NOT fenced: set ${slug} read-only there before it serves again; writes it acknowledged after entry ${out.held} reach ${profile.url} only if it comes back linked`);
|
|
369
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Substrio CLI: `substrio data count COLLECTION [where.FIELD=VALUE ...] [--data '{"where":{...}}']`.
|
|
3
|
+
*
|
|
4
|
+
* The same where filter `substrio data query` takes. The server's query.count answers by GET, whose query string
|
|
5
|
+
* has no `where`, so a filtered count runs as data.query with `count: true` (the server returns the count and
|
|
6
|
+
* no documents); an unfiltered one stays query.count. One owner of that choice: countPlan.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { parseOpArgs } from '../ops.mjs';
|
|
10
|
+
import { runOperation } from './op.mjs';
|
|
11
|
+
|
|
12
|
+
/** The operation of a count that has a filter, and the input it adds. */
|
|
13
|
+
export const FILTERED_COUNT = Object.freeze({ operation: 'data.query', defaults: Object.freeze({ count: true }) });
|
|
14
|
+
/** The operation of a count without one. */
|
|
15
|
+
export const PLAIN_COUNT = Object.freeze({ operation: 'query.count', defaults: Object.freeze({}) });
|
|
16
|
+
|
|
17
|
+
/** The input field that filters. */
|
|
18
|
+
const FILTER_FIELD = 'where';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Which operation counts for these arguments (pure): FILTERED_COUNT when the input they make holds a `where`,
|
|
22
|
+
* else PLAIN_COUNT. Arguments that do not parse are the operation's own error to report.
|
|
23
|
+
*/
|
|
24
|
+
export function countPlan(args) {
|
|
25
|
+
let input;
|
|
26
|
+
try {
|
|
27
|
+
({ input } = parseOpArgs(args));
|
|
28
|
+
} catch {
|
|
29
|
+
return PLAIN_COUNT;
|
|
30
|
+
}
|
|
31
|
+
const filtered = input !== null && typeof input === 'object' && !Array.isArray(input) && Object.hasOwn(input, FILTER_FIELD);
|
|
32
|
+
return filtered ? FILTERED_COUNT : PLAIN_COUNT;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Runs the count a group command resolved ({positional, rest}). */
|
|
36
|
+
export function cmdCount(api, resolved) {
|
|
37
|
+
const plan = countPlan(resolved.rest);
|
|
38
|
+
return runOperation(api, plan.operation, resolved.rest, { positional: resolved.positional, defaults: { ...plan.defaults } });
|
|
39
|
+
}
|