@tuongaz/seeflow 0.1.102 → 0.1.104

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 (75) hide show
  1. package/dist/web/assets/{architectureDiagram-3BPJPVTR-Cy5G99GE.js → architectureDiagram-3BPJPVTR-S06hgR1x.js} +1 -1
  2. package/dist/web/assets/{blockDiagram-GPEHLZMM-B1gN1ugF.js → blockDiagram-GPEHLZMM-ZdAkZGrj.js} +1 -1
  3. package/dist/web/assets/{c4Diagram-AAUBKEIU-CYuuPtx8.js → c4Diagram-AAUBKEIU-C5MfRz02.js} +1 -1
  4. package/dist/web/assets/channel-D-VTcFiH.js +1 -0
  5. package/dist/web/assets/{chart-YfoAGRGq.js → chart-BxTNHoKY.js} +1 -1
  6. package/dist/web/assets/{chunk-2J33WTMH-B4sFED7K.js → chunk-2J33WTMH-Bn46MReV.js} +1 -1
  7. package/dist/web/assets/{chunk-4BX2VUAB-D2Pr1i6l.js → chunk-4BX2VUAB-CXHVHaLs.js} +1 -1
  8. package/dist/web/assets/{chunk-55IACEB6-B0QJVegf.js → chunk-55IACEB6-CRgBKrhO.js} +1 -1
  9. package/dist/web/assets/{chunk-727SXJPM-Dc2HoDqb.js → chunk-727SXJPM-BH62wjtB.js} +1 -1
  10. package/dist/web/assets/{chunk-AQP2D5EJ-D30cXxxk.js → chunk-AQP2D5EJ-RxGAqK2A.js} +1 -1
  11. package/dist/web/assets/{chunk-FMBD7UC4-DsgHMGgF.js → chunk-FMBD7UC4-Cb-wgtwe.js} +1 -1
  12. package/dist/web/assets/{chunk-ND2GUHAM-n6Sr5izJ.js → chunk-ND2GUHAM-CJlFQBdh.js} +1 -1
  13. package/dist/web/assets/{chunk-QZHKN3VN-BA1s2pgD.js → chunk-QZHKN3VN-DjoTSKYv.js} +1 -1
  14. package/dist/web/assets/classDiagram-4FO5ZUOK-DoPcjHFc.js +1 -0
  15. package/dist/web/assets/classDiagram-v2-Q7XG4LA2-DoPcjHFc.js +1 -0
  16. package/dist/web/assets/{code-block-CeM4DS5T.js → code-block-DwqzBBWk.js} +1 -1
  17. package/dist/web/assets/{cose-bilkent-S5V4N54A-gJFCrqp6.js → cose-bilkent-S5V4N54A-DpGzn2ro.js} +1 -1
  18. package/dist/web/assets/{dagre-BM42HDAG-DJgtzuMT.js → dagre-BM42HDAG-CUapj-lK.js} +1 -1
  19. package/dist/web/assets/{diagram-2AECGRRQ-DfqMYNjs.js → diagram-2AECGRRQ-7ISjO-i-.js} +1 -1
  20. package/dist/web/assets/{diagram-5GNKFQAL-Bqivakd0.js → diagram-5GNKFQAL-DCPWI1Ok.js} +1 -1
  21. package/dist/web/assets/{diagram-KO2AKTUF-Cm0eKgu4.js → diagram-KO2AKTUF-zSsOIiq1.js} +1 -1
  22. package/dist/web/assets/{diagram-LMA3HP47-DCzPDQwE.js → diagram-LMA3HP47-BcqSq_nr.js} +1 -1
  23. package/dist/web/assets/{diagram-OG6HWLK6-L5YX65FQ.js → diagram-OG6HWLK6-Dw6nG7MD.js} +1 -1
  24. package/dist/web/assets/{erDiagram-TEJ5UH35-Ds6LiAGH.js → erDiagram-TEJ5UH35-B3QPOZAY.js} +1 -1
  25. package/dist/web/assets/{flowDiagram-I6XJVG4X-D50Ubn70.js → flowDiagram-I6XJVG4X-Ceu3sQuX.js} +1 -1
  26. package/dist/web/assets/{ganttDiagram-6RSMTGT7-Dkaswtst.js → ganttDiagram-6RSMTGT7-B26peYjY.js} +1 -1
  27. package/dist/web/assets/{gitGraphDiagram-PVQCEYII-OaI-_pfD.js → gitGraphDiagram-PVQCEYII-CX8DOFLm.js} +1 -1
  28. package/dist/web/assets/{iconify-MlxYu4AY.js → iconify-DAxofKes.js} +1 -1
  29. package/dist/web/assets/{index-B6U4bGj0.js → index-B7r8KKap.js} +1758 -1743
  30. package/dist/web/assets/{index.es-BNr31JLP.js → index.es-wzlW-y-I.js} +1 -1
  31. package/dist/web/assets/{infoDiagram-5YYISTIA-dIvLrSFw.js → infoDiagram-5YYISTIA-DDVBGLt8.js} +1 -1
  32. package/dist/web/assets/{ishikawaDiagram-YF4QCWOH-LveMmZUw.js → ishikawaDiagram-YF4QCWOH-C6qCp-Zn.js} +1 -1
  33. package/dist/web/assets/{journeyDiagram-JHISSGLW-B0NT14UG.js → journeyDiagram-JHISSGLW-DX8AxLaA.js} +1 -1
  34. package/dist/web/assets/{jspdf.es.min-DIDY-jNM.js → jspdf.es.min-ykdbTFYz.js} +3 -3
  35. package/dist/web/assets/{kanban-definition-UN3LZRKU-6Ze2WLaL.js → kanban-definition-UN3LZRKU-B8-itdXZ.js} +1 -1
  36. package/dist/web/assets/{linear-lr2_gH5v.js → linear-Dqxq5R6C.js} +1 -1
  37. package/dist/web/assets/{markdown-71DgXg9K.js → markdown-D9XDrg2s.js} +1 -1
  38. package/dist/web/assets/{mermaid.core-CSskwtFd.js → mermaid.core-DujK2Rzn.js} +4 -4
  39. package/dist/web/assets/{mindmap-definition-RKZ34NQL-55BhA-Da.js → mindmap-definition-RKZ34NQL-CXa-5n8a.js} +1 -1
  40. package/dist/web/assets/{pieDiagram-4H26LBE5-CMysRCVC.js → pieDiagram-4H26LBE5-CGlnQV5E.js} +1 -1
  41. package/dist/web/assets/{quadrantDiagram-W4KKPZXB-BDfPYcaj.js → quadrantDiagram-W4KKPZXB-L_Ap8kcw.js} +1 -1
  42. package/dist/web/assets/{requirementDiagram-4Y6WPE33-CNfdldEP.js → requirementDiagram-4Y6WPE33-B2Io1TQi.js} +1 -1
  43. package/dist/web/assets/{sankeyDiagram-5OEKKPKP-C_wMGAZ1.js → sankeyDiagram-5OEKKPKP-DeARBi7s.js} +1 -1
  44. package/dist/web/assets/{sequenceDiagram-3UESZ5HK-CbGM_Yv-.js → sequenceDiagram-3UESZ5HK-BR0jm0Tk.js} +1 -1
  45. package/dist/web/assets/{stateDiagram-AJRCARHV-BCSHXjtA.js → stateDiagram-AJRCARHV-Ck_33KQ3.js} +1 -1
  46. package/dist/web/assets/stateDiagram-v2-BHNVJYJU-D122sbeB.js +1 -0
  47. package/dist/web/assets/{time-Dm45mmVJ.js → time-DY4vPLRk.js} +1 -1
  48. package/dist/web/assets/{timeline-definition-PNZ67QCA-Cv6Hh2hS.js → timeline-definition-PNZ67QCA-DMm8bfvY.js} +1 -1
  49. package/dist/web/assets/{vennDiagram-CIIHVFJN-CNaR42oY.js → vennDiagram-CIIHVFJN-BEfqdSFT.js} +1 -1
  50. package/dist/web/assets/{wardley-L42UT6IY-BDpknWMg.js → wardley-L42UT6IY-Bmxjf0CJ.js} +1 -1
  51. package/dist/web/assets/{wardleyDiagram-YWT4CUSO-BxIDc-nV.js → wardleyDiagram-YWT4CUSO-D4ialTz3.js} +1 -1
  52. package/dist/web/assets/{xychartDiagram-2RQKCTM6-8Ir0d3kF.js → xychartDiagram-2RQKCTM6-nm_TLxlx.js} +1 -1
  53. package/dist/web/index.html +1 -1
  54. package/package.json +1 -1
  55. package/src/api.ts +241 -0
  56. package/src/atomic-write.ts +1 -1
  57. package/src/server.ts +19 -0
  58. package/src/share/sse-frame.ts +85 -0
  59. package/src/share/sse-outbound-queue.ts +173 -0
  60. package/src/share/sse-rate-limit.ts +205 -0
  61. package/src/share/sse-tap.ts +183 -0
  62. package/src/share-audit.ts +267 -0
  63. package/src/share-envelope.ts +152 -0
  64. package/src/share-file-request.ts +353 -0
  65. package/src/share-file-resolver.ts +68 -0
  66. package/src/share-file-upload.ts +595 -0
  67. package/src/share-files-manifest.ts +232 -0
  68. package/src/share-ratelimit.ts +69 -0
  69. package/src/share-rpc-schema.ts +249 -0
  70. package/src/share-transport.ts +205 -0
  71. package/src/share.ts +1561 -0
  72. package/dist/web/assets/channel-VQJmKMDU.js +0 -1
  73. package/dist/web/assets/classDiagram-4FO5ZUOK-Biiw_jnD.js +0 -1
  74. package/dist/web/assets/classDiagram-v2-Q7XG4LA2-Biiw_jnD.js +0 -1
  75. package/dist/web/assets/stateDiagram-v2-BHNVJYJU-Bbabof8W.js +0 -1
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Host-side files-manifest builder. Walks the currently registered project's
3
+ * per-node folders once on session start and emits one `files-manifest` frame
4
+ * per joining peer so the peer's canvas can prime cache keys and render
5
+ * placeholder sizing BEFORE any `file-request` fires.
6
+ *
7
+ * Manifest is metadata only — no file contents on the wire. Each entry carries
8
+ * the file's size + a sha256 prefix (16 hex chars) so the peer can detect a
9
+ * cache miss without round-tripping the body. The full 64-hex sha is preserved
10
+ * inside file-request / file-redirect payloads (US-060) where integrity matters;
11
+ * here we use a prefix to keep the manifest small.
12
+ *
13
+ * Walk depth is capped at 2 (files directly under `<flowDir>/nodes/<nodeId>/`
14
+ * plus one level of subdirectory) so a peer that drops a folder of nested
15
+ * assets doesn't explode the manifest. Total entries are capped at 5000 —
16
+ * if exceeded the manifest is truncated with a warn; join is never blocked.
17
+ *
18
+ * Incremental updates: after the initial build, `recordFileWritten` / `recordNodeRemoved`
19
+ * keep the in-memory map in sync with `file-upload` node-patched broadcasts +
20
+ * `removeNodeDir` calls, so a peer that joins mid-session sees the same
21
+ * post-upload state every other peer has converged on.
22
+ */
23
+
24
+ import { createHash } from 'node:crypto';
25
+ import type { Dirent } from 'node:fs';
26
+ import * as fsPromises from 'node:fs/promises';
27
+ import { dirname, join, sep as pathSep } from 'node:path';
28
+ import type { Registry } from './registry.ts';
29
+ import type { FilesManifestEntry, FilesManifestPayload } from './share-envelope.ts';
30
+
31
+ export const MAX_MANIFEST_ENTRIES = 5000;
32
+ const ETAG_PREFIX_LEN = 16;
33
+
34
+ interface InternalEntry {
35
+ nodeId: string;
36
+ relPath: string;
37
+ size: number;
38
+ etag: string;
39
+ }
40
+
41
+ export interface FilesManifestDeps {
42
+ registry: Registry;
43
+ // Test seams — defaults to node:fs/promises.
44
+ readdir?: (p: string) => Promise<Dirent[]>;
45
+ readFile?: (p: string) => Promise<Buffer>;
46
+ stat?: (p: string) => Promise<{ size: number }>;
47
+ }
48
+
49
+ export interface FilesManifestBuilder {
50
+ /** Walk every registered flow's nodes/ subtree and populate the in-memory map.
51
+ * Safe to call repeatedly — replaces the existing state. */
52
+ init(): Promise<void>;
53
+ /** Snapshot of the current manifest, capped at MAX_MANIFEST_ENTRIES. */
54
+ build(): FilesManifestPayload;
55
+ /** Record (or replace) a single file entry. Called by share.ts when a
56
+ * `file-upload` `node-patched` broadcast fires. */
57
+ recordFileWritten(input: {
58
+ absPath: string;
59
+ nodeId: string;
60
+ relPath: string;
61
+ size: number;
62
+ etag: string;
63
+ }): void;
64
+ /** Compute size + etag from raw bytes then `recordFileWritten`. Convenience
65
+ * for callers that already hold the post-write buffer. */
66
+ recordFileWrittenFromBytes(input: {
67
+ absPath: string;
68
+ nodeId: string;
69
+ relPath: string;
70
+ bytes: Uint8Array;
71
+ }): void;
72
+ /** Drop every entry whose `absPath` lives under the given node directory. */
73
+ recordNodeRemoved(nodeDirAbsPath: string): void;
74
+ /** Test/debug visibility into the underlying map. */
75
+ size(): number;
76
+ }
77
+
78
+ const NODE_ID_RE = /^node-[A-Za-z0-9]{10}$/;
79
+
80
+ const defaultReaddir = (p: string): Promise<Dirent[]> =>
81
+ fsPromises.readdir(p, { withFileTypes: true });
82
+ const defaultReadFile = (p: string): Promise<Buffer> => fsPromises.readFile(p) as Promise<Buffer>;
83
+ const defaultStat = async (p: string): Promise<{ size: number }> => {
84
+ const s = await fsPromises.stat(p);
85
+ return { size: s.size };
86
+ };
87
+
88
+ const etagFromBytes = (bytes: Uint8Array): string =>
89
+ createHash('sha256').update(bytes).digest('hex').slice(0, ETAG_PREFIX_LEN);
90
+
91
+ const flowDirOf = (flowPath: string): string => {
92
+ const d = dirname(flowPath);
93
+ return d === '.' ? '' : d;
94
+ };
95
+
96
+ const flowBaseAbs = (repoPath: string, flowPath: string): string => {
97
+ const fd = flowDirOf(flowPath);
98
+ return fd === '' ? repoPath : join(repoPath, fd);
99
+ };
100
+
101
+ export function createFilesManifestBuilder(deps: FilesManifestDeps): FilesManifestBuilder {
102
+ const readdir = deps.readdir ?? defaultReaddir;
103
+ const readFile = deps.readFile ?? defaultReadFile;
104
+ const stat = deps.stat ?? defaultStat;
105
+
106
+ // absPath -> entry. absPath chosen as the key because (a) it's the only
107
+ // identifier that uniquely names a file across flows and (b) it lets
108
+ // `recordNodeRemoved(nodeDirAbsPath)` drop everything under a single prefix
109
+ // without a secondary index.
110
+ const entries = new Map<string, InternalEntry>();
111
+
112
+ const walkNodeDir = async (flowBase: string, nodeId: string): Promise<void> => {
113
+ const nodeDir = join(flowBase, 'nodes', nodeId);
114
+ let level1: Dirent[];
115
+ try {
116
+ level1 = await readdir(nodeDir);
117
+ } catch {
118
+ return;
119
+ }
120
+ for (const d of level1) {
121
+ if (d.isSymbolicLink()) continue;
122
+ if (d.isFile()) {
123
+ await ingestFile(nodeDir, nodeId, d.name, [d.name]);
124
+ continue;
125
+ }
126
+ if (!d.isDirectory()) continue;
127
+ // Depth 2: one level of subdirectory. Files inside are included; nested
128
+ // directories below this are NOT descended into.
129
+ const subDir = join(nodeDir, d.name);
130
+ let level2: Dirent[];
131
+ try {
132
+ level2 = await readdir(subDir);
133
+ } catch {
134
+ continue;
135
+ }
136
+ for (const inner of level2) {
137
+ if (inner.isSymbolicLink()) continue;
138
+ if (!inner.isFile()) continue;
139
+ await ingestFile(subDir, nodeId, inner.name, [d.name, inner.name]);
140
+ }
141
+ }
142
+ };
143
+
144
+ const ingestFile = async (
145
+ fileDir: string,
146
+ nodeId: string,
147
+ fileName: string,
148
+ relSegments: string[],
149
+ ): Promise<void> => {
150
+ const absPath = join(fileDir, fileName);
151
+ // Per-file relPath is relative to the flow folder. Always forward-slash
152
+ // separated so the wire payload is platform-neutral.
153
+ const relPath = `nodes/${nodeId}/${relSegments.join('/')}`;
154
+ let size = 0;
155
+ let etag = '';
156
+ try {
157
+ const st = await stat(absPath);
158
+ size = st.size;
159
+ } catch {
160
+ return;
161
+ }
162
+ try {
163
+ const bytes = await readFile(absPath);
164
+ etag = etagFromBytes(bytes);
165
+ } catch {
166
+ return;
167
+ }
168
+ entries.set(absPath, { nodeId, relPath, size, etag });
169
+ };
170
+
171
+ return {
172
+ async init() {
173
+ entries.clear();
174
+ for (const entry of deps.registry.list()) {
175
+ const flowBase = flowBaseAbs(entry.repoPath, entry.flowPath);
176
+ const nodesRoot = join(flowBase, 'nodes');
177
+ let nodeDirs: Dirent[];
178
+ try {
179
+ nodeDirs = await readdir(nodesRoot);
180
+ } catch {
181
+ continue;
182
+ }
183
+ for (const d of nodeDirs) {
184
+ if (d.isSymbolicLink()) continue;
185
+ if (!d.isDirectory()) continue;
186
+ if (!NODE_ID_RE.test(d.name)) continue;
187
+ await walkNodeDir(flowBase, d.name);
188
+ }
189
+ }
190
+ },
191
+ build() {
192
+ const all = [...entries.values()].map(
193
+ ({ nodeId, relPath, size, etag }): FilesManifestEntry => ({
194
+ nodeId,
195
+ relPath,
196
+ size,
197
+ etag,
198
+ }),
199
+ );
200
+ if (all.length > MAX_MANIFEST_ENTRIES) {
201
+ console.warn(
202
+ `[share] files-manifest truncated: ${all.length} entries exceeds cap ${MAX_MANIFEST_ENTRIES}`,
203
+ );
204
+ return { entries: all.slice(0, MAX_MANIFEST_ENTRIES) };
205
+ }
206
+ return { entries: all };
207
+ },
208
+ recordFileWritten({ absPath, nodeId, relPath, size, etag }) {
209
+ entries.set(absPath, { nodeId, relPath, size, etag });
210
+ },
211
+ recordFileWrittenFromBytes({ absPath, nodeId, relPath, bytes }) {
212
+ entries.set(absPath, {
213
+ nodeId,
214
+ relPath,
215
+ size: bytes.byteLength,
216
+ etag: etagFromBytes(bytes),
217
+ });
218
+ },
219
+ recordNodeRemoved(nodeDirAbsPath) {
220
+ // Match either the directory itself or anything strictly under it. A
221
+ // bare prefix check would falsely match a sibling directory whose name
222
+ // starts with `nodeDirAbsPath` (e.g. `/a/node-x` vs `/a/node-xx-data`).
223
+ const prefix = nodeDirAbsPath.endsWith(pathSep) ? nodeDirAbsPath : nodeDirAbsPath + pathSep;
224
+ for (const key of entries.keys()) {
225
+ if (key === nodeDirAbsPath || key.startsWith(prefix)) entries.delete(key);
226
+ }
227
+ },
228
+ size() {
229
+ return entries.size;
230
+ },
231
+ };
232
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Per-peer token-bucket rate limiter for inbound share frames.
3
+ *
4
+ * Each peer holds a bucket of `burst` tokens, refilling at `ratePerSec`
5
+ * tokens/second. check() deducts a token if available and returns ok; if
6
+ * empty, it returns the milliseconds the caller should wait before retrying.
7
+ * Time is injected via `now()` so tests can drive the bucket synchronously.
8
+ */
9
+
10
+ export interface RateLimitOk {
11
+ ok: true;
12
+ }
13
+
14
+ export interface RateLimitDenied {
15
+ ok: false;
16
+ retryAfterMs: number;
17
+ }
18
+
19
+ export type RateLimitResult = RateLimitOk | RateLimitDenied;
20
+
21
+ export interface RateLimiter {
22
+ check(peerId: string): RateLimitResult;
23
+ }
24
+
25
+ export interface RateLimiterOpts {
26
+ ratePerSec: number;
27
+ burst?: number;
28
+ now?: () => number;
29
+ }
30
+
31
+ interface Bucket {
32
+ tokens: number;
33
+ lastRefillMs: number;
34
+ }
35
+
36
+ export function createRateLimiter(opts: RateLimiterOpts): RateLimiter {
37
+ const ratePerSec = opts.ratePerSec;
38
+ const capacity = opts.burst ?? opts.ratePerSec;
39
+ const nowFn = opts.now ?? Date.now;
40
+ const buckets = new Map<string, Bucket>();
41
+
42
+ const refill = (bucket: Bucket, now: number) => {
43
+ const elapsedSec = (now - bucket.lastRefillMs) / 1000;
44
+ if (elapsedSec <= 0) return;
45
+ const refilled = bucket.tokens + elapsedSec * ratePerSec;
46
+ bucket.tokens = refilled > capacity ? capacity : refilled;
47
+ bucket.lastRefillMs = now;
48
+ };
49
+
50
+ return {
51
+ check(peerId) {
52
+ const now = nowFn();
53
+ let bucket = buckets.get(peerId);
54
+ if (!bucket) {
55
+ bucket = { tokens: capacity, lastRefillMs: now };
56
+ buckets.set(peerId, bucket);
57
+ } else {
58
+ refill(bucket, now);
59
+ }
60
+ if (bucket.tokens >= 1) {
61
+ bucket.tokens -= 1;
62
+ return { ok: true };
63
+ }
64
+ const deficit = 1 - bucket.tokens;
65
+ const retryAfterMs = Math.ceil((deficit / ratePerSec) * 1000);
66
+ return { ok: false, retryAfterMs };
67
+ },
68
+ };
69
+ }
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Shared RPC envelope schema for the live-share WebSocket protocol (phase 4).
3
+ *
4
+ * ⚠ BYTE-FOR-BYTE MIRROR: this file MUST stay identical to
5
+ * /Users/tuongaz/dev/seeflow-viewer/src/lib/share-rpc-schema.ts.
6
+ * The integration check at apps/studio/integration/share-rpc-schema-sync.it.ts
7
+ * reads both files and asserts strict bytewise equality. Any edit here MUST
8
+ * be mirrored to the viewer copy in the same commit, or CI fails.
9
+ *
10
+ * Self-contained on purpose: NO imports from './operations.ts' or './schema.ts'.
11
+ * Those paths don't resolve in the viewer repo, and byte-for-byte mirroring
12
+ * requires identical imports across both files.
13
+ *
14
+ * Body-shape strictness:
15
+ * - PositionBody + ReorderBody are tiny self-contained Zod shapes — inlined
16
+ * here so obviously-malformed payloads (NaN x, unknown reorder op) reject
17
+ * at the wire layer.
18
+ * - Node/connector add + patch bodies are loose `z.record(z.unknown())`. The
19
+ * strict re-validation runs at impl-dispatch time in operations.ts via
20
+ * `NodePatchBodySchema` / `ConnectorPatchBodySchema` / the post-merge
21
+ * `ResolvedFlowSchema` reparse (US-038's `handleRpcFrame`). Treat this file
22
+ * as an envelope schema, not a substitute for the impl-layer guards.
23
+ *
24
+ * Op allowlist (9 ops): addNode, patchNode, moveNode, reorderNode, deleteNode,
25
+ * addConnector, patchConnector, deleteConnector, addBulk. Unknown ops are
26
+ * rejected by the discriminated union; the per-op `.strict()` rejects unknown
27
+ * top-level keys so a `{ op: 'addNode', nodeId, position }` cross-op shape
28
+ * fails fast instead of silently accepting the wrong wrapper.
29
+ *
30
+ * `addBulk` is the only op without `.strict()` because its body carries the
31
+ * optional `nodes` + `connectors` arrays alongside `op` + `flowId`; the
32
+ * 100-item-per-array cap mirrors operations.ts's `FlowBulkBodyShape`. The
33
+ * "at least one non-empty" refine from `FlowBulkBodySchema` is NOT applied
34
+ * here (discriminatedUnion requires bare ZodObjects, not ZodEffects) — the
35
+ * non-empty check runs in US-038's handler before dispatching to
36
+ * `addFlowBulkImpl`.
37
+ */
38
+
39
+ import { z } from 'zod';
40
+
41
+ const FlowIdSchema = z.string().min(1);
42
+ const NodeIdSchema = z.string().min(1);
43
+ const ConnectorIdSchema = z.string().min(1);
44
+ const FrameIdSchema = z.string().min(1);
45
+
46
+ const PositionSchema = z.object({
47
+ x: z.number().finite(),
48
+ y: z.number().finite(),
49
+ });
50
+
51
+ const ReorderSchema = z.discriminatedUnion('op', [
52
+ z.object({ op: z.literal('forward') }),
53
+ z.object({ op: z.literal('backward') }),
54
+ z.object({ op: z.literal('toFront') }),
55
+ z.object({ op: z.literal('toBack') }),
56
+ z.object({ op: z.literal('toIndex'), index: z.number().int().nonnegative() }),
57
+ ]);
58
+
59
+ const NodeBodySchema = z.record(z.unknown());
60
+ const ConnectorBodySchema = z.record(z.unknown());
61
+ const NodePatchBodyShape = z.record(z.unknown());
62
+ const ConnectorPatchBodyShape = z.record(z.unknown());
63
+
64
+ const BULK_MAX_ITEMS = 100;
65
+ const BulkArraySchema = z.array(z.record(z.unknown())).max(BULK_MAX_ITEMS);
66
+
67
+ export const AddNodeOpSchema = z
68
+ .object({
69
+ op: z.literal('addNode'),
70
+ flowId: FlowIdSchema,
71
+ node: NodeBodySchema,
72
+ })
73
+ .strict();
74
+
75
+ export const PatchNodeOpSchema = z
76
+ .object({
77
+ op: z.literal('patchNode'),
78
+ flowId: FlowIdSchema,
79
+ nodeId: NodeIdSchema,
80
+ patch: NodePatchBodyShape,
81
+ })
82
+ .strict();
83
+
84
+ export const MoveNodeOpSchema = z
85
+ .object({
86
+ op: z.literal('moveNode'),
87
+ flowId: FlowIdSchema,
88
+ nodeId: NodeIdSchema,
89
+ position: PositionSchema,
90
+ })
91
+ .strict();
92
+
93
+ export const ReorderNodeOpSchema = z
94
+ .object({
95
+ op: z.literal('reorderNode'),
96
+ flowId: FlowIdSchema,
97
+ nodeId: NodeIdSchema,
98
+ reorder: ReorderSchema,
99
+ })
100
+ .strict();
101
+
102
+ export const DeleteNodeOpSchema = z
103
+ .object({
104
+ op: z.literal('deleteNode'),
105
+ flowId: FlowIdSchema,
106
+ nodeId: NodeIdSchema,
107
+ })
108
+ .strict();
109
+
110
+ export const AddConnectorOpSchema = z
111
+ .object({
112
+ op: z.literal('addConnector'),
113
+ flowId: FlowIdSchema,
114
+ connector: ConnectorBodySchema,
115
+ })
116
+ .strict();
117
+
118
+ export const PatchConnectorOpSchema = z
119
+ .object({
120
+ op: z.literal('patchConnector'),
121
+ flowId: FlowIdSchema,
122
+ connectorId: ConnectorIdSchema,
123
+ patch: ConnectorPatchBodyShape,
124
+ })
125
+ .strict();
126
+
127
+ export const DeleteConnectorOpSchema = z
128
+ .object({
129
+ op: z.literal('deleteConnector'),
130
+ flowId: FlowIdSchema,
131
+ connectorId: ConnectorIdSchema,
132
+ })
133
+ .strict();
134
+
135
+ export const AddBulkOpSchema = z
136
+ .object({
137
+ op: z.literal('addBulk'),
138
+ flowId: FlowIdSchema,
139
+ nodes: BulkArraySchema.optional(),
140
+ connectors: BulkArraySchema.optional(),
141
+ })
142
+ .strict();
143
+
144
+ export const RpcOpSchema = z.discriminatedUnion('op', [
145
+ AddNodeOpSchema,
146
+ PatchNodeOpSchema,
147
+ MoveNodeOpSchema,
148
+ ReorderNodeOpSchema,
149
+ DeleteNodeOpSchema,
150
+ AddConnectorOpSchema,
151
+ PatchConnectorOpSchema,
152
+ DeleteConnectorOpSchema,
153
+ AddBulkOpSchema,
154
+ ]);
155
+
156
+ export const RpcFrameSchema = z
157
+ .object({
158
+ v: z.literal(1),
159
+ type: z.literal('rpc'),
160
+ id: FrameIdSchema,
161
+ payload: RpcOpSchema,
162
+ })
163
+ .strict();
164
+
165
+ /**
166
+ * Attribution metadata attached to every accepted edit. `peerId` is either the
167
+ * literal `'host'` (when the studio owner originates the edit) or matches a
168
+ * known remote peer's id. `displayName` is the human label rendered by the
169
+ * peer SPA's toast / cursor label.
170
+ *
171
+ * Cross-validation (peerId === 'host' OR peerId ∈ knownPeers) is enforced at
172
+ * runtime by the share controller — the wire schema can't reach the peer set,
173
+ * so it accepts any non-empty string and the controller drops broadcasts with
174
+ * peerIds that don't match.
175
+ */
176
+ export const AttributionSchema = z
177
+ .object({
178
+ peerId: z.string().min(1),
179
+ displayName: z.string().min(1),
180
+ })
181
+ .strict();
182
+
183
+ export type Attribution = z.infer<typeof AttributionSchema>;
184
+
185
+ export const RpcResultPayloadSchema = z.discriminatedUnion('ok', [
186
+ z
187
+ .object({
188
+ ok: z.literal(true),
189
+ result: z.unknown().optional(),
190
+ attributedTo: AttributionSchema.optional(),
191
+ })
192
+ .strict(),
193
+ z.object({ ok: z.literal(false), reason: z.string() }).strict(),
194
+ ]);
195
+
196
+ export const RpcResultFrameSchema = z
197
+ .object({
198
+ v: z.literal(1),
199
+ type: z.literal('rpc-result'),
200
+ id: FrameIdSchema,
201
+ payload: RpcResultPayloadSchema,
202
+ })
203
+ .strict();
204
+
205
+ export type RpcOp = z.infer<typeof RpcOpSchema>;
206
+ export type RpcFrame = z.infer<typeof RpcFrameSchema>;
207
+ export type RpcResultFrame = z.infer<typeof RpcResultFrameSchema>;
208
+
209
+ /**
210
+ * `node-patched` broadcast frame (US-039 host → US-042 peer apply).
211
+ *
212
+ * Host emits this after every accepted `rpc` op so all peers (including the
213
+ * originator) converge on the canonical post-op snapshot. Payload shape mirrors
214
+ * `computeNodePatchedDiff` in `apps/studio/src/share.ts`:
215
+ *
216
+ * { flowId, op, version, diff: { kind, ...fields } }
217
+ *
218
+ * where `op` is one of the 9 RpcOp strings and `diff.kind` is one of
219
+ * 'move' | 'patch' | 'add' | 'delete' | 'reorder' | 'bulk'
220
+ * (the latter only for `addBulk`). The diff body is intentionally loose at
221
+ * the wire layer — peers switch on `op` + `diff.kind` to drive the apply.
222
+ * Strict diff shapes live host-side in `computeNodePatchedDiff`.
223
+ *
224
+ * `version` is a per-flow monotonic counter. Peers track
225
+ * `lastAppliedVersion[flowId]` and drop frames where `version <=` the last
226
+ * applied — guards against out-of-order delivery on reconnect.
227
+ */
228
+ const NodePatchedDiffSchema = z.record(z.unknown());
229
+
230
+ export const NodePatchedFramePayloadSchema = z
231
+ .object({
232
+ flowId: FlowIdSchema,
233
+ op: z.string().min(1),
234
+ version: z.number().int().nonnegative(),
235
+ diff: NodePatchedDiffSchema,
236
+ attributedTo: AttributionSchema,
237
+ })
238
+ .passthrough();
239
+
240
+ export const NodePatchedFrameSchema = z
241
+ .object({
242
+ v: z.literal(1),
243
+ type: z.literal('node-patched'),
244
+ payload: NodePatchedFramePayloadSchema,
245
+ })
246
+ .passthrough();
247
+
248
+ export type NodePatchedFramePayload = z.infer<typeof NodePatchedFramePayloadSchema>;
249
+ export type NodePatchedFrame = z.infer<typeof NodePatchedFrameSchema>;