@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.
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,408 @@
1
+ /**
2
+ * Substrio CLI: hot reload for `substrio dev`. The Reloader keeps what the server
3
+ * runs (a fingerprint per unit of the app) and, when the watcher says
4
+ * something changed, brings the server to what is on disk, doing only what
5
+ * changed:
6
+ *
7
+ * a function's file changed -> that one function: function.check, then function.deploy
8
+ * a function's file removed -> function.delete
9
+ * rules, MCP tools, plans, cron,
10
+ * triggers, site, [auth], or a
11
+ * function's declaration in
12
+ * substrio.toml changed -> one new version, made live on the app (no backup)
13
+ * a collection's file changed -> that collection made (when missing) and its schema set,
14
+ * before any version; its rules go with the version's rules
15
+ * (a file that is removed leaves the collection and its data)
16
+ * any apply -> every declared collection the server no longer has is made again
17
+ * (a collection deleted on the server, then a save of anything);
18
+ * a server declaration (index, expiry, aggregate) the files no
19
+ * longer name is reported (drift) and left in place, or dropped
20
+ * with --prune (never a field's undeclared adaptive key)
21
+ * [roles.NAME], [auth.providers.NAME]
22
+ * -> role.create/update and auth.provider.update, before the version
23
+ * a build step's input changed -> that step runs; its outputs come back as changes
24
+ * substrio.toml does not parse -> refused, typed; the server keeps what it runs
25
+ *
26
+ * A change the server refuses (a source that does not compile, a version that
27
+ * fails its check) leaves the server on its last good state, is reported once,
28
+ * and is tried again only when the file changes again. Saves arriving together
29
+ * share one apply (a trailing-edge deadline timer, no polling), and one apply
30
+ * runs at a time: a save during an apply is applied after it, from what is on
31
+ * disk then. Data is never touched.
32
+ */
33
+
34
+ import { join } from 'node:path';
35
+ import { performance } from 'node:perf_hooks';
36
+
37
+ import { deployFunction, loadApp, makeLive, publishVersion, readApp, runBuild } from '../apply.mjs';
38
+ import { sha256 } from '../blobs.mjs';
39
+ import { CliError, ErrorCode } from '../cli.mjs';
40
+ import { applyCollections, collectionDrift, collectionsMember, missingCollections, pruneDrift } from '../collections.mjs';
41
+ import { applyIdentity, declaredIdentity, identityFingerprint } from '../identity.mjs';
42
+ import { declaredFunction } from '../manifest.mjs';
43
+ import { ChangeKind } from './watch.mjs';
44
+
45
+ /** What happened, as the Reloader reports it. */
46
+ export const ReloadKind = Object.freeze({
47
+ FUNCTION_DEPLOYED: 'function-deployed',
48
+ FUNCTION_REMOVED: 'function-removed',
49
+ FUNCTION_REFUSED: 'function-refused', // the source did not check: diagnostics, nothing changed
50
+ VERSION_APPLIED: 'version-applied',
51
+ VERSION_REFUSED: 'version-refused', // the server refused the version: error, nothing changed
52
+ COLLECTIONS_APPLIED: 'collections-applied', // the declared collections were made and their schemas set
53
+ COLLECTIONS_REFUSED: 'collections-refused', // the server refused one: error; it is tried again when a file changes
54
+ COLLECTIONS_DRIFT: 'collections-drift', // the server has an index, expiry or aggregate the files no longer name; left in place
55
+ COLLECTIONS_PRUNED: 'collections-pruned', // --prune dropped a declaration the files no longer name
56
+ IDENTITY_APPLIED: 'identity-applied', // the declared roles and sign-in providers were made or updated
57
+ IDENTITY_REFUSED: 'identity-refused', // the server refused one: error; it is tried again with the next apply
58
+ BUILD_RAN: 'build-ran',
59
+ BUILD_FAILED: 'build-failed',
60
+ CONFIG_REFUSED: 'config-refused', // substrio.toml (or a file it names) is not valid: nothing changed
61
+ UNCHANGED: 'unchanged', // a save that changed nothing the server runs
62
+ FAILED: 'failed', // anything else that went wrong: the error; the reloader goes on
63
+ });
64
+
65
+ /** The parts of an app that go with a version, not with one function. */
66
+ const UnitKind = Object.freeze({
67
+ RULES: 'rules',
68
+ MCP: 'mcp',
69
+ PLANS: 'plans',
70
+ SITE: 'site',
71
+ CRON: 'cron',
72
+ TRIGGERS: 'triggers',
73
+ QUEUES: 'queues',
74
+ SETTINGS: 'settings',
75
+ COLLECTIONS: 'collections',
76
+ IDENTITY: 'identity',
77
+ });
78
+
79
+ const json = (value) => JSON.stringify(value ?? null);
80
+
81
+ /** What an app runs, as fingerprints: one per function, one per version-wide unit. */
82
+ export function fingerprint(loaded, manifest) {
83
+ const functions = new Map(loaded.app.functions.map((f) => [f.name, `${f.hash}|${json(declaredFunction(f.name, f.declared))}`]));
84
+ const units = new Map([
85
+ [UnitKind.RULES, loaded.app.rules?.hash ?? ''],
86
+ [UnitKind.MCP, loaded.app.mcp?.hash ?? ''],
87
+ [UnitKind.PLANS, json(loaded.plans)],
88
+ [UnitKind.SITE, loaded.app.site ? sha256(json(loaded.app.site.files.map((f) => [f.path, f.hash]))) : ''],
89
+ [UnitKind.CRON, json(manifest.cron)],
90
+ [UnitKind.TRIGGERS, json(manifest.triggers)],
91
+ [UnitKind.QUEUES, json(manifest.queues)],
92
+ [UnitKind.SETTINGS, json(loaded.settings)],
93
+ [UnitKind.COLLECTIONS, json(loaded.collections)],
94
+ [UnitKind.IDENTITY, loaded.identity ? identityFingerprint(loaded.identity) : ''],
95
+ ]);
96
+ return { functions, units };
97
+ }
98
+
99
+ /** The units whose fingerprint differs between two fingerprints. */
100
+ export function changedUnits(before, after) {
101
+ return [...after.units].filter(([unit, value]) => before.units.get(unit) !== value).map(([unit]) => unit);
102
+ }
103
+
104
+ /** The functions in `after` that are new or differ from `before`, and the ones only `before` has. */
105
+ export function changedFunctions(before, after) {
106
+ const changed = [...after.functions].filter(([name, value]) => before.functions.get(name) !== value).map(([name]) => name);
107
+ const removed = [...before.functions.keys()].filter((name) => !after.functions.has(name));
108
+ return { changed, removed };
109
+ }
110
+
111
+ const EMPTY = Object.freeze({ functions: new Map(), units: new Map() });
112
+
113
+ export class Reloader {
114
+ #applied = EMPTY; // what the server runs
115
+ #refused = new Map(); // function name -> the fingerprint the server refused last (reported once)
116
+ #collections = new Map(); // collection name -> the fingerprint of what the server was given (its schema and indexes)
117
+ #dirty = null; // what changed since the last apply, or null
118
+ #timer = null;
119
+ #running = null; // the apply in flight
120
+ #closed = false;
121
+ #idle = [];
122
+ #app = null; // {config, manifest}, the last substrio.toml that was valid
123
+ #functionCache = new Map(); // entry path -> buildFunction's cache, across every apply
124
+ #siteDir = null; // the app's site folder (public_dir), where a function's site_vars are read
125
+
126
+ /**
127
+ * @param {object} deps
128
+ * @param {string} deps.cwd the app's directory
129
+ * @param {object} deps.api the tenant's API client (blob uploads)
130
+ * @param {object} deps.ops the server's operation catalog
131
+ * @param {(name: string, input: object) => Promise<any>} deps.invoke throws a CliError on a failed call
132
+ * @param {(event: object) => void} deps.report receives every {kind, ...} event (ReloadKind)
133
+ * @param {number} [deps.debounceMs] how long saves are gathered
134
+ * @param {boolean} [deps.build] run build steps (--no-build turns it off)
135
+ * @param {boolean} [deps.prune] drop a server declaration the files no longer name (--prune)
136
+ * @param {(steps: object[], cwd: string) => void} [deps.runBuild] the build runner (a test replaces it)
137
+ * @param {(name: string, absPaths: string[]) => void} [deps.onFunctionInputs] told a function's
138
+ * bundled inputs after each build, so the watcher can watch them too
139
+ * @param {(projectDir: string) => string | null} [deps.resolveEsbuild] forwarded to loadApp, then
140
+ * to every buildFunction call (its own default is the real resolution); a test states its own.
141
+ */
142
+ constructor({ cwd, api, ops, invoke, report, debounceMs = 50, build = true, prune = false, runBuild: run = runBuild, onFunctionInputs, resolveEsbuild }) {
143
+ Object.assign(this, { cwd, api, ops, invoke, report, debounceMs, build, prune, onFunctionInputs, resolveEsbuild });
144
+ this.runBuild = run;
145
+ }
146
+
147
+ /** The first apply: everything on disk, as one version. */
148
+ async start() {
149
+ this.#dirty = { config: true, steps: new Set(), all: true };
150
+ await this.#kick();
151
+ }
152
+
153
+ /** A change from the watcher. */
154
+ note(change) {
155
+ if (this.#closed) return;
156
+ this.#dirty ??= { config: false, steps: new Set(), all: false };
157
+ if (change.kind === ChangeKind.CONFIG || change.kind === ChangeKind.RESYNC) this.#dirty.config = true;
158
+ if (change.kind === ChangeKind.RESYNC) this.#dirty.all = true;
159
+ if (change.kind === ChangeKind.BUILD_INPUT) this.#dirty.steps.add(change.step);
160
+ if (change.kind === ChangeKind.FUNCTION) {
161
+ // A function's own change, or one of its bundled inputs', must be tried again even if it
162
+ // was refused before; its code hash (built from every input) carries the rest.
163
+ for (const name of change.names ?? [change.name]) this.#refused.delete(name);
164
+ }
165
+ clearTimeout(this.#timer);
166
+ this.#timer = setTimeout(() => { this.#timer = null; void this.#kick(); }, this.debounceMs);
167
+ }
168
+
169
+ /** Resolves once nothing is pending or running. */
170
+ idle() {
171
+ if (this.#timer === null && this.#running === null && this.#dirty === null) return Promise.resolve();
172
+ return new Promise((resolve) => this.#idle.push(resolve));
173
+ }
174
+
175
+ async close() {
176
+ this.#closed = true;
177
+ clearTimeout(this.#timer);
178
+ this.#timer = null;
179
+ this.#dirty = null;
180
+ await this.#running;
181
+ this.#release();
182
+ }
183
+
184
+ #release() {
185
+ if (this.#timer !== null || this.#running !== null || this.#dirty !== null) return;
186
+ for (const resolve of this.#idle.splice(0)) resolve();
187
+ }
188
+
189
+ // Starts an apply unless one runs; the one running picks up what arrived meanwhile when it ends. A timer
190
+ // that fires after the running apply already took its change finds nothing dirty: no apply is started,
191
+ // and nothing is left marked as running.
192
+ #kick() {
193
+ if (this.#running !== null) return this.#running;
194
+ if (this.#dirty === null || this.#closed) {
195
+ this.#release();
196
+ return Promise.resolve();
197
+ }
198
+ const run = this.#drain();
199
+ this.#running = run;
200
+ // Cleared in a continuation, which always runs after the assignment above: a marker cleared inside
201
+ // the run itself could be cleared first and then overwritten by a finished promise, and every later
202
+ // save would wait on it forever.
203
+ const ended = () => {
204
+ this.#running = null;
205
+ this.#release();
206
+ };
207
+ run.then(ended, ended);
208
+ return run;
209
+ }
210
+
211
+ async #drain() {
212
+ for (; this.#dirty !== null && !this.#closed;) {
213
+ const dirty = this.#dirty;
214
+ this.#dirty = null;
215
+ try {
216
+ await this.#apply(dirty);
217
+ } catch (err) {
218
+ this.report({ kind: ReloadKind.FAILED, error: err });
219
+ }
220
+ }
221
+ }
222
+
223
+ async #apply(dirty) {
224
+ const started = performance.now();
225
+ const ms = () => Math.round(performance.now() - started);
226
+
227
+ // substrio.toml: read strictly; an invalid one keeps the last good app.
228
+ if (dirty.config || this.#app === null) {
229
+ try {
230
+ this.#app = readApp(this.cwd);
231
+ } catch (err) {
232
+ this.report({ kind: ReloadKind.CONFIG_REFUSED, error: err, ms: ms() });
233
+ return;
234
+ }
235
+ }
236
+ const { config, manifest } = this.#app;
237
+
238
+ // Build steps whose inputs changed (all of them at the first apply).
239
+ if (this.build && manifest.build.length > 0) {
240
+ const steps = dirty.all ? manifest.build.map((_, i) => i) : [...dirty.steps].sort((a, b) => a - b);
241
+ for (const index of steps) {
242
+ const step = manifest.build[index];
243
+ if (step === undefined) continue;
244
+ const began = performance.now();
245
+ try {
246
+ this.runBuild([step], this.cwd);
247
+ this.report({ kind: ReloadKind.BUILD_RAN, step, ms: Math.round(performance.now() - began) });
248
+ } catch (err) {
249
+ this.report({ kind: ReloadKind.BUILD_FAILED, step, error: err, ms: Math.round(performance.now() - began) });
250
+ return;
251
+ }
252
+ }
253
+ }
254
+
255
+ // Development ships no precompressed sidecars: brotli at its best quality on every save would be slow.
256
+ const devManifest = manifest.site ? { ...manifest, site: { ...manifest.site, precompress: null } } : manifest;
257
+ let loaded;
258
+ try {
259
+ loaded = { ...await loadApp(config, devManifest, this.cwd, undefined, this.#functionCache, this.resolveEsbuild), identity: declaredIdentity(config) };
260
+ this.#siteDir = join(this.cwd, config.public_dir || 'public');
261
+ } catch (err) {
262
+ this.report({ kind: ReloadKind.CONFIG_REFUSED, error: err, ms: ms() });
263
+ return;
264
+ }
265
+ const now = fingerprint(loaded, devManifest);
266
+ const units = changedUnits(this.#applied, now);
267
+ // A declared collection the server no longer has (deleted there) is made again by this apply, whether or
268
+ // not its file changed.
269
+ const gone = await missingCollections(this.invoke, loaded.collections);
270
+ for (const name of gone) this.#collections.delete(name);
271
+ if (gone.length > 0 && !units.includes(UnitKind.COLLECTIONS)) units.push(UnitKind.COLLECTIONS);
272
+ const { changed, removed } = changedFunctions(this.#applied, now);
273
+
274
+ // A function the server already refused in exactly this form is not tried (or reported) again.
275
+ const toDeploy = changed.filter((name) => this.#refused.get(name) !== now.functions.get(name));
276
+ if (units.length === 0 && toDeploy.length === 0 && removed.length === 0) {
277
+ if (changed.length === 0) this.report({ kind: ReloadKind.UNCHANGED, ms: ms() });
278
+ return;
279
+ }
280
+
281
+ // A version carries every function; when the change was only collections or identity, applied directly,
282
+ // the functions that changed are still deployed below.
283
+ if (units.length > 0 && await this.#applyVersion(loaded, now, units, ms)) return;
284
+ for (const name of toDeploy) await this.#deploy(loaded, now, name);
285
+ for (const name of removed) await this.#remove(name);
286
+ }
287
+
288
+ async #deploy(loaded, now, name) {
289
+ const began = performance.now();
290
+ const fn = loaded.app.functions.find((f) => f.name === name);
291
+ this.onFunctionInputs?.(name, fn.inputs.map((p) => join(this.cwd, p)));
292
+ if (fn.buildError) {
293
+ // It does not even parse, or needs a package esbuild could not resolve: nothing to send
294
+ // function.check, so none is sent; the last good deploy (if any) stands, same as a
295
+ // source function.check itself refuses.
296
+ this.#refused.set(name, now.functions.get(name));
297
+ this.report({ kind: ReloadKind.FUNCTION_REFUSED, name, ms: Math.round(performance.now() - began), diagnostics: fn.buildError.diagnostics ?? [] });
298
+ return;
299
+ }
300
+ try {
301
+ const result = await deployFunction(this.invoke, { name, script: fn.data.toString('utf-8'), declared: fn.declared, siteDir: this.#siteDir });
302
+ const ms = Math.round(performance.now() - began);
303
+ if (result.ok) {
304
+ this.#applied = { functions: new Map(this.#applied.functions).set(name, now.functions.get(name)), units: this.#applied.units };
305
+ this.#refused.delete(name);
306
+ this.report({ kind: ReloadKind.FUNCTION_DEPLOYED, name, ms, diagnostics: result.diagnostics });
307
+ } else {
308
+ this.#refused.set(name, now.functions.get(name));
309
+ this.report({ kind: ReloadKind.FUNCTION_REFUSED, name, ms, diagnostics: result.diagnostics });
310
+ }
311
+ } catch (err) {
312
+ this.#refused.set(name, now.functions.get(name));
313
+ // The server has no field naming "this import needed bundling"; esbuildAvailable (never
314
+ // asked to bundle this function) plus the generic code it shares with every other way a
315
+ // source fails to become a handler is the honest best signal, without reading it again.
316
+ const esbuildHint = fn.esbuildAvailable === false && err instanceof CliError && err.code === ErrorCode.INVALID_ARGUMENT;
317
+ this.report({ kind: ReloadKind.FUNCTION_REFUSED, name, ms: Math.round(performance.now() - began), error: err, diagnostics: [], esbuildHint });
318
+ }
319
+ }
320
+
321
+ async #remove(name) {
322
+ const began = performance.now();
323
+ try {
324
+ await this.invoke('function.delete', { name });
325
+ const functions = new Map(this.#applied.functions);
326
+ functions.delete(name);
327
+ this.#applied = { functions, units: this.#applied.units };
328
+ this.#refused.delete(name);
329
+ this.report({ kind: ReloadKind.FUNCTION_REMOVED, name, ms: Math.round(performance.now() - began) });
330
+ } catch (err) {
331
+ this.report({ kind: ReloadKind.VERSION_REFUSED, error: err, ms: Math.round(performance.now() - began) });
332
+ }
333
+ }
334
+
335
+ // True when the apply is over (a version was made, or refused, or a collection was refused); false when only
336
+ // collections and identity were applied and no version was needed.
337
+ async #applyVersion(loaded, now, changed, ms) {
338
+ // The collections first: the rules of the version are about them. They are applied to the server directly
339
+ // (a development server has no sandbox to try them in), and only when some are declared.
340
+ const collections = changed.includes(UnitKind.COLLECTIONS);
341
+ const identity = changed.includes(UnitKind.IDENTITY);
342
+ const units = changed.filter((unit) => unit !== UnitKind.COLLECTIONS && unit !== UnitKind.IDENTITY);
343
+ if (collections) {
344
+ // Only the collections the server was not given in this form.
345
+ const fingerprintOf = (c) => json(collectionsMember([c]));
346
+ const pending = loaded.collections.filter((c) => this.#collections.get(c.name) !== fingerprintOf(c));
347
+ const done = [];
348
+ try {
349
+ if (pending.length > 0) {
350
+ const result = await applyCollections(this.invoke, pending, done);
351
+ this.report({ kind: ReloadKind.COLLECTIONS_APPLIED, collections: result.applied, warnings: result.warnings, ms: ms() });
352
+ }
353
+ const drift = await collectionDrift(this.invoke, loaded.collections);
354
+ if (drift.length > 0) {
355
+ if (this.prune) {
356
+ await pruneDrift(this.invoke, drift);
357
+ this.report({ kind: ReloadKind.COLLECTIONS_PRUNED, drift, ms: ms() });
358
+ } else {
359
+ this.report({ kind: ReloadKind.COLLECTIONS_DRIFT, drift, ms: ms() });
360
+ }
361
+ }
362
+ this.#applied = {
363
+ functions: this.#applied.functions,
364
+ units: new Map(this.#applied.units).set(UnitKind.COLLECTIONS, now.units.get(UnitKind.COLLECTIONS)),
365
+ };
366
+ } catch (err) {
367
+ if (!(err instanceof CliError)) throw err;
368
+ this.report({ kind: ReloadKind.COLLECTIONS_REFUSED, error: err, ms: ms() });
369
+ return true;
370
+ } finally {
371
+ // What was applied stands even when a later collection was refused.
372
+ for (const { collection } of done) this.#collections.set(collection, fingerprintOf(pending.find((c) => c.name === collection)));
373
+ }
374
+ }
375
+ // The roles and providers: independent of the version, so a refusal is reported and the version goes on.
376
+ let identityRefused = false;
377
+ if (identity) {
378
+ const done = [];
379
+ try {
380
+ const result = await applyIdentity(this.invoke, loaded.identity, done);
381
+ if (result.applied.length > 0) this.report({ kind: ReloadKind.IDENTITY_APPLIED, applied: result.applied, ms: ms() });
382
+ this.#applied = {
383
+ functions: this.#applied.functions,
384
+ units: new Map(this.#applied.units).set(UnitKind.IDENTITY, now.units.get(UnitKind.IDENTITY)),
385
+ };
386
+ } catch (err) {
387
+ if (!(err instanceof CliError)) throw err;
388
+ identityRefused = true;
389
+ this.report({ kind: ReloadKind.IDENTITY_REFUSED, error: err, applied: done, ms: ms() });
390
+ }
391
+ }
392
+ if (units.length === 0) return false;
393
+ try {
394
+ const published = await publishVersion(this.api, this.ops, loaded, this.invoke);
395
+ await makeLive(this.invoke, published.number, { backup: false });
396
+ // A refused identity stays unapplied, so the next apply tries it again.
397
+ this.#applied = identityRefused
398
+ ? { functions: now.functions, units: new Map(now.units).set(UnitKind.IDENTITY, this.#applied.units.get(UnitKind.IDENTITY)) }
399
+ : now;
400
+ this.#refused.clear();
401
+ this.report({ kind: ReloadKind.VERSION_APPLIED, version: published.number, units, functions: loaded.app.functions.length, ms: ms() });
402
+ } catch (err) {
403
+ if (!(err instanceof CliError)) throw err;
404
+ this.report({ kind: ReloadKind.VERSION_REFUSED, units, error: err, ms: ms() });
405
+ }
406
+ return true;
407
+ }
408
+ }