@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,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
|
+
}
|