@flow-as-code/cli 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 +202 -0
- package/README.md +446 -0
- package/dist/aws.d.ts +28 -0
- package/dist/aws.d.ts.map +1 -0
- package/dist/aws.js +104 -0
- package/dist/aws.js.map +1 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +132 -0
- package/dist/bin.js.map +1 -0
- package/dist/bridge/exportFiles.d.ts +16 -0
- package/dist/bridge/exportFiles.d.ts.map +1 -0
- package/dist/bridge/exportFiles.js +95 -0
- package/dist/bridge/exportFiles.js.map +1 -0
- package/dist/bridge/pair.d.ts +103 -0
- package/dist/bridge/pair.d.ts.map +1 -0
- package/dist/bridge/pair.js +219 -0
- package/dist/bridge/pair.js.map +1 -0
- package/dist/bridge/protocol.d.ts +187 -0
- package/dist/bridge/protocol.d.ts.map +1 -0
- package/dist/bridge/protocol.js +132 -0
- package/dist/bridge/protocol.js.map +1 -0
- package/dist/bridge/server.d.ts +95 -0
- package/dist/bridge/server.d.ts.map +1 -0
- package/dist/bridge/server.js +669 -0
- package/dist/bridge/server.js.map +1 -0
- package/dist/cdk-scaffold.d.ts +16 -0
- package/dist/cdk-scaffold.d.ts.map +1 -0
- package/dist/cdk-scaffold.js +28 -0
- package/dist/cdk-scaffold.js.map +1 -0
- package/dist/codegen.d.ts +5 -0
- package/dist/codegen.d.ts.map +1 -0
- package/dist/codegen.js +27 -0
- package/dist/codegen.js.map +1 -0
- package/dist/diff.d.ts +33 -0
- package/dist/diff.d.ts.map +1 -0
- package/dist/diff.js +225 -0
- package/dist/diff.js.map +1 -0
- package/dist/docs.d.ts +46 -0
- package/dist/docs.d.ts.map +1 -0
- package/dist/docs.js +223 -0
- package/dist/docs.js.map +1 -0
- package/dist/emit.d.ts +8 -0
- package/dist/emit.d.ts.map +1 -0
- package/dist/emit.js +55 -0
- package/dist/emit.js.map +1 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +25 -0
- package/dist/errors.js.map +1 -0
- package/dist/export.d.ts +27 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/export.js +126 -0
- package/dist/export.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -0
- package/dist/lint.d.ts +6 -0
- package/dist/lint.d.ts.map +1 -0
- package/dist/lint.js +31 -0
- package/dist/lint.js.map +1 -0
- package/dist/render.d.ts +6 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +49 -0
- package/dist/render.js.map +1 -0
- package/dist/run.d.ts +13 -0
- package/dist/run.d.ts.map +1 -0
- package/dist/run.js +38 -0
- package/dist/run.js.map +1 -0
- package/dist/simulate.d.ts +35 -0
- package/dist/simulate.d.ts.map +1 -0
- package/dist/simulate.js +194 -0
- package/dist/simulate.js.map +1 -0
- package/dist/studio.d.ts +23 -0
- package/dist/studio.d.ts.map +1 -0
- package/dist/studio.js +120 -0
- package/dist/studio.js.map +1 -0
- package/dist/synth-resolve-hook.d.ts +19 -0
- package/dist/synth-resolve-hook.d.ts.map +1 -0
- package/dist/synth-resolve-hook.js +127 -0
- package/dist/synth-resolve-hook.js.map +1 -0
- package/dist/synth-runner.d.ts +12 -0
- package/dist/synth-runner.d.ts.map +1 -0
- package/dist/synth-runner.js +145 -0
- package/dist/synth-runner.js.map +1 -0
- package/dist/synth.d.ts +55 -0
- package/dist/synth.d.ts.map +1 -0
- package/dist/synth.js +327 -0
- package/dist/synth.js.map +1 -0
- package/dist/watch.d.ts +66 -0
- package/dist/watch.d.ts.map +1 -0
- package/dist/watch.js +293 -0
- package/dist/watch.js.map +1 -0
- package/package.json +85 -0
- package/schema/flowdoc-0.1.schema.json +669 -0
- package/schema/scenario-0.1.schema.json +318 -0
- package/src/aws.ts +149 -0
- package/src/bin.ts +179 -0
- package/src/bridge/exportFiles.ts +116 -0
- package/src/bridge/pair.ts +312 -0
- package/src/bridge/protocol.ts +289 -0
- package/src/bridge/server.ts +827 -0
- package/src/cdk-scaffold.ts +41 -0
- package/src/codegen.ts +36 -0
- package/src/diff.ts +279 -0
- package/src/docs.ts +249 -0
- package/src/emit.ts +69 -0
- package/src/errors.ts +27 -0
- package/src/export.ts +167 -0
- package/src/index.ts +27 -0
- package/src/lint.ts +42 -0
- package/src/render.ts +58 -0
- package/src/run.ts +42 -0
- package/src/simulate.ts +244 -0
- package/src/studio.ts +143 -0
- package/src/synth-resolve-hook.ts +152 -0
- package/src/synth-runner.ts +157 -0
- package/src/synth.ts +380 -0
- package/src/watch.ts +388 -0
package/src/watch.ts
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 The flow-as-code Authors
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
// Watch engine: keeps <name>.flow.ts and <name>.flowdoc.json pairs in sync
|
|
6
|
+
// inside one directory. A library, not a command: the studio server (A11)
|
|
7
|
+
// consumes these events, and `flow-cli studio` will sit on top.
|
|
8
|
+
//
|
|
9
|
+
// Sync direction here is ts -> doc only. On a ts change the file is re-synthed
|
|
10
|
+
// in the same sandboxed child process `flow-cli synth` uses, and the FlowDoc
|
|
11
|
+
// is rewritten UNLESS the doc on disk was edited by someone else since this
|
|
12
|
+
// watcher last wrote or observed it AND that edit does not carry the
|
|
13
|
+
// sourceHash of the previous ts content. That state is dirty-both: the
|
|
14
|
+
// watcher emits "conflict" and writes nothing. Never silently overwrite
|
|
15
|
+
// (docs/01-flowdoc-spec.md, invariants 3 and the sourceHash dirty guard).
|
|
16
|
+
//
|
|
17
|
+
// One event is emitted for a change that alters nothing: a builder file edited
|
|
18
|
+
// back to the exact bytes of the last successful sync, after a failed one.
|
|
19
|
+
// Consumers latch "error" (the studio keeps a badge up until the document syncs
|
|
20
|
+
// again), so a state that ends by being undone has to end with a "synced" or
|
|
21
|
+
// the badge outlives the condition it reports.
|
|
22
|
+
|
|
23
|
+
import { createHash } from "node:crypto";
|
|
24
|
+
import { readFile } from "node:fs/promises";
|
|
25
|
+
import { writeFile } from "node:fs/promises";
|
|
26
|
+
import { basename, join, resolve } from "node:path";
|
|
27
|
+
|
|
28
|
+
import { watch as chokidarWatch, type FSWatcher } from "chokidar";
|
|
29
|
+
|
|
30
|
+
import type { FlowDoc } from "@flow-as-code/core";
|
|
31
|
+
import { serializeWithMeta, synthFile } from "./synth.js";
|
|
32
|
+
|
|
33
|
+
const TS_SUFFIX = ".flow.ts";
|
|
34
|
+
const DOC_SUFFIX = ".flowdoc.json";
|
|
35
|
+
|
|
36
|
+
export interface SyncedEvent {
|
|
37
|
+
tsPath: string;
|
|
38
|
+
docPath: string;
|
|
39
|
+
name: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ConflictEvent {
|
|
43
|
+
tsPath: string;
|
|
44
|
+
docPath: string;
|
|
45
|
+
name: string;
|
|
46
|
+
reason: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ErrorEvent {
|
|
50
|
+
path: string;
|
|
51
|
+
message: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface WatcherEvents {
|
|
55
|
+
synced: SyncedEvent;
|
|
56
|
+
conflict: ConflictEvent;
|
|
57
|
+
error: ErrorEvent;
|
|
58
|
+
/** Initial scan finished and every startup synth settled. Not part of the
|
|
59
|
+
* A04 event contract; a convenience for consumers and tests. */
|
|
60
|
+
ready: Record<string, never>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface FlowWatcher {
|
|
64
|
+
on<K extends keyof WatcherEvents>(event: K, listener: (payload: WatcherEvents[K]) => void): this;
|
|
65
|
+
off<K extends keyof WatcherEvents>(event: K, listener: (payload: WatcherEvents[K]) => void): this;
|
|
66
|
+
once<K extends keyof WatcherEvents>(
|
|
67
|
+
event: K,
|
|
68
|
+
listener: (payload: WatcherEvents[K]) => void,
|
|
69
|
+
): this;
|
|
70
|
+
/**
|
|
71
|
+
* Records a pair another part of this process just wrote as the clean
|
|
72
|
+
* baseline, so the watcher recognizes the write as its own instead of
|
|
73
|
+
* treating it as an external edit.
|
|
74
|
+
*
|
|
75
|
+
* The studio bridge (A11) writes both halves of a pair when the canvas
|
|
76
|
+
* saves: the FlowDoc and the regenerated builder source. Without this the
|
|
77
|
+
* watcher would see a ts change whose doc "changed externally" and emit a
|
|
78
|
+
* conflict for an edit the same process just made.
|
|
79
|
+
*
|
|
80
|
+
* Pass the exact bytes written. Call it AFTER both files are on disk:
|
|
81
|
+
* chokidar's awaitWriteFinish window (50 ms) is orders of magnitude longer
|
|
82
|
+
* than the gap between the writes and this call, so no event can be routed
|
|
83
|
+
* against a half-updated ledger. Contents that are not passed leave that
|
|
84
|
+
* half of the ledger alone.
|
|
85
|
+
*/
|
|
86
|
+
noteWrite(name: string, contents: { tsContent?: string; docContent?: string }): void;
|
|
87
|
+
close(): Promise<void>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Per-pair sync ledger. Hashes are hex sha256 of file bytes. */
|
|
91
|
+
interface LedgerEntry {
|
|
92
|
+
/** Bytes of the doc as last written by us or observed in a clean state. */
|
|
93
|
+
lastDocHash?: string;
|
|
94
|
+
/** Bytes of the ts content those doc bytes were synthed from / seen with. */
|
|
95
|
+
lastTsHash?: string;
|
|
96
|
+
/**
|
|
97
|
+
* The last change to this pair's builder file ended in an `error` event, so
|
|
98
|
+
* consumers are still showing the pair as out of sync. Only then does a
|
|
99
|
+
* change back to the last-synced bytes deserve a `synced`: see the no-op
|
|
100
|
+
* branch in handleTsChange.
|
|
101
|
+
*/
|
|
102
|
+
errored?: boolean;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function hashHex(bytes: Buffer | string): string {
|
|
106
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
class Watcher implements FlowWatcher {
|
|
110
|
+
private readonly listeners = new Map<keyof WatcherEvents, Set<(payload: never) => void>>();
|
|
111
|
+
private readonly ledger = new Map<string, LedgerEntry>();
|
|
112
|
+
/** Per-pair task chain so two changes to one pair never synth concurrently. */
|
|
113
|
+
private readonly chains = new Map<string, Promise<void>>();
|
|
114
|
+
private readonly fsWatcher: FSWatcher;
|
|
115
|
+
private readonly dir: string;
|
|
116
|
+
private closed = false;
|
|
117
|
+
|
|
118
|
+
constructor(dir: string) {
|
|
119
|
+
this.dir = resolve(dir);
|
|
120
|
+
// chokidar v5 takes no globs: watch the directory, filter paths here.
|
|
121
|
+
// awaitWriteFinish debounces editors' partial/atomic writes; the
|
|
122
|
+
// thresholds are far below the 1 second sync budget.
|
|
123
|
+
this.fsWatcher = chokidarWatch(this.dir, {
|
|
124
|
+
ignoreInitial: false,
|
|
125
|
+
depth: 0,
|
|
126
|
+
awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 10 },
|
|
127
|
+
});
|
|
128
|
+
this.fsWatcher.on("add", (path) => this.route(path));
|
|
129
|
+
this.fsWatcher.on("change", (path) => this.route(path));
|
|
130
|
+
this.fsWatcher.on("error", (err) => {
|
|
131
|
+
this.emit("error", {
|
|
132
|
+
path: this.dir,
|
|
133
|
+
message: err instanceof Error ? err.message : String(err),
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
this.fsWatcher.on("ready", () => {
|
|
137
|
+
// Let the initial adds settle before declaring readiness.
|
|
138
|
+
void this.settled().then(() => {
|
|
139
|
+
if (!this.closed) this.emit("ready", {});
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
on<K extends keyof WatcherEvents>(event: K, listener: (payload: WatcherEvents[K]) => void) {
|
|
145
|
+
let set = this.listeners.get(event);
|
|
146
|
+
if (set === undefined) {
|
|
147
|
+
set = new Set();
|
|
148
|
+
this.listeners.set(event, set);
|
|
149
|
+
}
|
|
150
|
+
set.add(listener as (payload: never) => void);
|
|
151
|
+
return this;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
off<K extends keyof WatcherEvents>(event: K, listener: (payload: WatcherEvents[K]) => void) {
|
|
155
|
+
this.listeners.get(event)?.delete(listener as (payload: never) => void);
|
|
156
|
+
return this;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
once<K extends keyof WatcherEvents>(event: K, listener: (payload: WatcherEvents[K]) => void) {
|
|
160
|
+
const wrapped = (payload: WatcherEvents[K]) => {
|
|
161
|
+
this.off(event, wrapped);
|
|
162
|
+
listener(payload);
|
|
163
|
+
};
|
|
164
|
+
return this.on(event, wrapped);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
noteWrite(name: string, contents: { tsContent?: string; docContent?: string }): void {
|
|
168
|
+
const entry = this.entry(name);
|
|
169
|
+
if (contents.tsContent !== undefined) entry.lastTsHash = hashHex(contents.tsContent);
|
|
170
|
+
if (contents.docContent !== undefined) entry.lastDocHash = hashHex(contents.docContent);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async close(): Promise<void> {
|
|
174
|
+
this.closed = true;
|
|
175
|
+
await this.fsWatcher.close();
|
|
176
|
+
await this.settled();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private emit<K extends keyof WatcherEvents>(event: K, payload: WatcherEvents[K]): void {
|
|
180
|
+
for (const listener of [...(this.listeners.get(event) ?? [])]) {
|
|
181
|
+
(listener as (p: WatcherEvents[K]) => void)(payload);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private settled(): Promise<void> {
|
|
186
|
+
return Promise.all([...this.chains.values()]).then(() => undefined);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private route(path: string): void {
|
|
190
|
+
const file = basename(path);
|
|
191
|
+
let name: string | undefined;
|
|
192
|
+
if (file.endsWith(TS_SUFFIX)) name = file.slice(0, -TS_SUFFIX.length);
|
|
193
|
+
else if (file.endsWith(DOC_SUFFIX)) name = file.slice(0, -DOC_SUFFIX.length);
|
|
194
|
+
if (name === undefined || name === "") return;
|
|
195
|
+
this.enqueue(name, file.endsWith(TS_SUFFIX));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private enqueue(name: string, tsChanged: boolean): void {
|
|
199
|
+
const prev = this.chains.get(name) ?? Promise.resolve();
|
|
200
|
+
const next = prev.then(() =>
|
|
201
|
+
tsChanged ? this.handleTsChange(name) : this.handleDocChange(name),
|
|
202
|
+
);
|
|
203
|
+
// Keep the chain alive even if a handler slips an exception through.
|
|
204
|
+
this.chains.set(
|
|
205
|
+
name,
|
|
206
|
+
next.catch((e: unknown) => {
|
|
207
|
+
this.emit("error", {
|
|
208
|
+
path: join(this.dir, name + TS_SUFFIX),
|
|
209
|
+
message: e instanceof Error ? e.message : String(e),
|
|
210
|
+
});
|
|
211
|
+
}),
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private entry(name: string): LedgerEntry {
|
|
216
|
+
let e = this.ledger.get(name);
|
|
217
|
+
if (e === undefined) {
|
|
218
|
+
e = {};
|
|
219
|
+
this.ledger.set(name, e);
|
|
220
|
+
}
|
|
221
|
+
return e;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* A doc file appeared or changed. Our own writes echo back here and are
|
|
226
|
+
* recognized by hash; anything else is an external edit. The ledger keeps
|
|
227
|
+
* the last CLEAN state, so an external edit deliberately does not update
|
|
228
|
+
* it: the divergence is detected on the next ts change.
|
|
229
|
+
*/
|
|
230
|
+
private async handleDocChange(name: string): Promise<void> {
|
|
231
|
+
const entry = this.entry(name);
|
|
232
|
+
const docPath = join(this.dir, name + DOC_SUFFIX);
|
|
233
|
+
let bytes: Buffer;
|
|
234
|
+
try {
|
|
235
|
+
bytes = await readFile(docPath);
|
|
236
|
+
} catch {
|
|
237
|
+
return; // deleted between event and read; the next ts change re-creates it
|
|
238
|
+
}
|
|
239
|
+
const docHash = hashHex(bytes);
|
|
240
|
+
if (entry.lastDocHash === undefined) {
|
|
241
|
+
// First observation of this doc (startup scan or a doc that appeared
|
|
242
|
+
// before its ts): record it as the observed baseline.
|
|
243
|
+
entry.lastDocHash = docHash;
|
|
244
|
+
}
|
|
245
|
+
// Otherwise: external edit (or our own echo, which matches lastDocHash
|
|
246
|
+
// and needs nothing). Leave the ledger pointing at the clean state.
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private async handleTsChange(name: string): Promise<void> {
|
|
250
|
+
if (this.closed) return;
|
|
251
|
+
const entry = this.entry(name);
|
|
252
|
+
const tsPath = join(this.dir, name + TS_SUFFIX);
|
|
253
|
+
const docPath = join(this.dir, name + DOC_SUFFIX);
|
|
254
|
+
|
|
255
|
+
let tsBytes: Buffer;
|
|
256
|
+
try {
|
|
257
|
+
tsBytes = await readFile(tsPath);
|
|
258
|
+
} catch {
|
|
259
|
+
return; // deleted between event and read
|
|
260
|
+
}
|
|
261
|
+
const tsHash = hashHex(tsBytes);
|
|
262
|
+
if (tsHash === entry.lastTsHash) {
|
|
263
|
+
// Normally an echo of our own write, or a touch: nothing to say. But it
|
|
264
|
+
// is also how a broken edit gets undone. Ctrl+Z back to the bytes we last
|
|
265
|
+
// synced from produces no synth and no event, so a consumer that latched
|
|
266
|
+
// the preceding `error` (the studio's "Code out of sync" badge) kept
|
|
267
|
+
// showing it until some unrelated edit happened. The doc on disk is still
|
|
268
|
+
// the one these bytes produced, so the pair IS in sync: say so.
|
|
269
|
+
if (entry.errored === true) {
|
|
270
|
+
entry.errored = false;
|
|
271
|
+
this.emit("synced", { tsPath, docPath, name });
|
|
272
|
+
}
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Read the doc side to run the dirty guard.
|
|
277
|
+
let docBytes: Buffer | undefined;
|
|
278
|
+
try {
|
|
279
|
+
docBytes = await readFile(docPath);
|
|
280
|
+
} catch {
|
|
281
|
+
docBytes = undefined;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (docBytes !== undefined) {
|
|
285
|
+
const docHash = hashHex(docBytes);
|
|
286
|
+
const docSourceHash = readSourceHash(docBytes);
|
|
287
|
+
|
|
288
|
+
if (entry.lastTsHash === undefined) {
|
|
289
|
+
// First look at this ts while a doc already exists (startup scan, or
|
|
290
|
+
// a pair dirty since startup). If the doc carries this ts content's
|
|
291
|
+
// hash the pair is in sync: baseline it without a synth. Anything
|
|
292
|
+
// else is dirty in an unknowable direction, so surface it instead of
|
|
293
|
+
// overwriting. Deliberately independent of lastDocHash: chokidar's
|
|
294
|
+
// initial add order (ts before doc or doc before ts) must not change
|
|
295
|
+
// the outcome.
|
|
296
|
+
if (docSourceHash === `sha256:${tsHash}`) {
|
|
297
|
+
entry.lastDocHash = docHash;
|
|
298
|
+
entry.lastTsHash = tsHash;
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
this.emit("conflict", {
|
|
302
|
+
tsPath,
|
|
303
|
+
docPath,
|
|
304
|
+
name,
|
|
305
|
+
reason:
|
|
306
|
+
"doc exists but its meta.sourceHash does not match the ts content, and this " +
|
|
307
|
+
"watcher has not seen the pair in sync; run `flow-cli synth` explicitly or " +
|
|
308
|
+
"remove the stale side",
|
|
309
|
+
});
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const docChangedExternally = entry.lastDocHash !== undefined && docHash !== entry.lastDocHash;
|
|
314
|
+
if (docChangedExternally && docSourceHash !== `sha256:${entry.lastTsHash}`) {
|
|
315
|
+
// Dirty-both: the doc was edited externally (studio or hand edit)
|
|
316
|
+
// AND the ts changed. Never silently overwrite either side.
|
|
317
|
+
this.emit("conflict", {
|
|
318
|
+
tsPath,
|
|
319
|
+
docPath,
|
|
320
|
+
name,
|
|
321
|
+
reason:
|
|
322
|
+
"both sides changed: the flowdoc was edited since the last sync " +
|
|
323
|
+
"and the ts file changed too; resolve manually and re-save one side",
|
|
324
|
+
});
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Clean (or doc missing): re-synth in the sandboxed child and write.
|
|
330
|
+
try {
|
|
331
|
+
const { flows, sourceHash } = await synthFile(tsPath);
|
|
332
|
+
const doc = pickDoc(flows, name);
|
|
333
|
+
if (doc === undefined) {
|
|
334
|
+
entry.errored = true;
|
|
335
|
+
this.emit("error", {
|
|
336
|
+
path: tsPath,
|
|
337
|
+
message:
|
|
338
|
+
`${basename(tsPath)} exports ${flows.length} flows and none is named "${name}"; ` +
|
|
339
|
+
`the watcher pairs ${name}${TS_SUFFIX} with ${name}${DOC_SUFFIX} by name`,
|
|
340
|
+
});
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const outBytes = serializeWithMeta(doc, sourceHash);
|
|
344
|
+
await writeFile(docPath, outBytes, "utf8");
|
|
345
|
+
entry.lastDocHash = hashHex(outBytes);
|
|
346
|
+
entry.lastTsHash = tsHash;
|
|
347
|
+
entry.errored = false;
|
|
348
|
+
this.emit("synced", { tsPath, docPath, name });
|
|
349
|
+
} catch (e) {
|
|
350
|
+
entry.errored = true;
|
|
351
|
+
this.emit("error", { path: tsPath, message: e instanceof Error ? e.message : String(e) });
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* The flow whose name matches the file base name, else a lone export. Exported
|
|
358
|
+
* so the studio bridge pairs a builder file with its FlowDoc by exactly the
|
|
359
|
+
* rule the watcher uses, rather than a second one that could disagree.
|
|
360
|
+
*/
|
|
361
|
+
export function pickDoc(
|
|
362
|
+
flows: { name: string; doc: FlowDoc }[],
|
|
363
|
+
name: string,
|
|
364
|
+
): FlowDoc | undefined {
|
|
365
|
+
const named = flows.find((f) => f.name === name);
|
|
366
|
+
if (named !== undefined) return named.doc;
|
|
367
|
+
const only = flows.length === 1 ? flows[0] : undefined;
|
|
368
|
+
return only?.doc;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function readSourceHash(docBytes: Buffer): string | undefined {
|
|
372
|
+
try {
|
|
373
|
+
const doc = JSON.parse(docBytes.toString("utf8")) as FlowDoc;
|
|
374
|
+
const hash = doc.meta?.sourceHash;
|
|
375
|
+
return typeof hash === "string" ? hash : undefined;
|
|
376
|
+
} catch {
|
|
377
|
+
return undefined; // unparseable doc counts as an external edit with no provenance
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Watches `dir` (non-recursive) and keeps every <name>.flow.ts /
|
|
383
|
+
* <name>.flowdoc.json pair in sync, ts -> doc, with the sourceHash dirty
|
|
384
|
+
* guard described at the top of this file.
|
|
385
|
+
*/
|
|
386
|
+
export function createWatcher(dir: string): FlowWatcher {
|
|
387
|
+
return new Watcher(dir);
|
|
388
|
+
}
|