@nodaro/shared 2.24.0 → 2.26.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodaro/shared",
3
- "version": "2.24.0",
3
+ "version": "2.26.0",
4
4
  "description": "Shared types, model catalog, wire contracts, and structural vocabularies for the Nodaro platform and SDK.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -0,0 +1,205 @@
1
+ import { describe, it, expect } from "vitest"
2
+
3
+ import {
4
+ STUDIO_SHOT_TRANSIENT_KEYS,
5
+ STUDIO_TRANSIENT_KEYS,
6
+ stripStudioTransientSettings,
7
+ } from "../studio-transient.js"
8
+
9
+ /**
10
+ * The public projection of `settings.studio` (D12).
11
+ *
12
+ * A shared production is read by anyone holding the link, and the document
13
+ * carries the OWNER's working state beside the film: the recycle bin (every
14
+ * shot, still and clip they deleted, prompts and urls intact), the jobs in
15
+ * flight, and an unsaved editor draft.
16
+ *
17
+ * The fixture below is a real saved document, pinned here byte for byte, and
18
+ * the LEVEL is the whole finding this test exists for: the in-flight markers
19
+ * are written PER SHOT, inside `settings.studio.shots[]`, and a strip that only
20
+ * walks the top level hands a viewer all of them while a fixture shaped to the
21
+ * top level says it does not.
22
+ */
23
+
24
+ const PENDING = {
25
+ jobId: "job-2",
26
+ provider: "seedance-2",
27
+ prompt: "a slow dolly in",
28
+ startedAt: 1_756_000_000_000,
29
+ }
30
+
31
+ const TRASHED = {
32
+ kind: "still",
33
+ id: "trash-1",
34
+ shotId: "s1",
35
+ index: 0,
36
+ deletedAt: "2026-09-01T10:00:00.000Z",
37
+ stillBase: { nodeId: "img-1", provider: "flux-2", prompt: "a lighthouse at dawn" },
38
+ result: { url: "https://cdn/deleted.png" },
39
+ }
40
+
41
+ /** A saved document carrying every marker its writer can write. */
42
+ function written(): Record<string, unknown> {
43
+ return {
44
+ studio: {
45
+ version: 3,
46
+ shots: [
47
+ {
48
+ id: "s1",
49
+ imageNodeId: "img-1",
50
+ stillProvider: "flux-2",
51
+ pendingClips: [{ ...PENDING }],
52
+ },
53
+ ],
54
+ selectedShotId: "s1",
55
+ shotOrder: ["img-1"],
56
+ shared: true,
57
+ freecutDraftUrl: "https://cdn/draft.json",
58
+ trash: [{ ...TRASHED }],
59
+ },
60
+ }
61
+ }
62
+
63
+ /** `settings.studio` of a stripped document. */
64
+ function studioOf(settings: unknown): Record<string, unknown> {
65
+ return (settings as { studio: Record<string, unknown> }).studio
66
+ }
67
+
68
+ describe("stripStudioTransientSettings", () => {
69
+ it("drops the bin and the draft the writer put at the top level", () => {
70
+ const settings = written()
71
+ // The oracle: the document really does carry these two here.
72
+ expect(studioOf(settings).trash).toHaveLength(1)
73
+ expect(studioOf(settings).freecutDraftUrl).toBe("https://cdn/draft.json")
74
+
75
+ const studio = studioOf(stripStudioTransientSettings(settings))
76
+ expect(studio.trash).toBeUndefined()
77
+ expect(studio.freecutDraftUrl).toBeUndefined()
78
+ })
79
+
80
+ it("drops the in-flight markers the writer put PER SHOT", () => {
81
+ const settings = written()
82
+ // The oracle again, and the whole point: `pendingClips` is a SHOT's key.
83
+ const stored = (studioOf(settings).shots as Array<Record<string, unknown>>)[0]
84
+ expect(stored.pendingClips).toEqual([PENDING])
85
+
86
+ const shots = studioOf(stripStudioTransientSettings(settings)).shots as Array<
87
+ Record<string, unknown>
88
+ >
89
+ expect(shots[0].pendingClips).toBeUndefined()
90
+ // ...and the shot itself survives, film intact.
91
+ expect(shots[0].id).toBe("s1")
92
+ expect(shots[0].imageNodeId).toBe("img-1")
93
+ })
94
+
95
+ it("leaves the film — the shots, the order, the share flag — untouched", () => {
96
+ const studio = studioOf(stripStudioTransientSettings(written()))
97
+ expect(studio.version).toBe(3)
98
+ expect(studio.shotOrder).toEqual(["img-1"])
99
+ expect(studio.shared).toBe(true)
100
+ expect(studio.selectedShotId).toBe("s1")
101
+ })
102
+
103
+ it("never mutates the caller's document", () => {
104
+ const settings = written()
105
+ const before = JSON.stringify(settings)
106
+ stripStudioTransientSettings(settings)
107
+ expect(JSON.stringify(settings)).toBe(before)
108
+ })
109
+
110
+ it("hands back the very same object when there is nothing to strip", () => {
111
+ // An ordinary share read of an ordinary production allocates nothing.
112
+ const settings: Record<string, unknown> = {
113
+ studio: { version: 3, shots: [{ id: "s1" }], selectedShotId: "s1", shotOrder: [] },
114
+ }
115
+ expect(stripStudioTransientSettings(settings)).toBe(settings)
116
+ })
117
+
118
+ it("drops a shot's pendingStills — the still marker D5 lands there", () => {
119
+ // `pendingStills` is additive: the generation routes write it onto the same
120
+ // shot entry `pendingClips` rides on, so the strip has to know the key
121
+ // before its writer exists — otherwise the first framing batch in flight
122
+ // ships to every share viewer.
123
+ const settings = written()
124
+ const studio = studioOf(settings)
125
+ const shots = (studio.shots as Array<Record<string, unknown>>).map((s) => ({
126
+ ...s,
127
+ pendingStills: [{ jobId: "job-1", batchId: "batch-1", count: 4 }],
128
+ }))
129
+ const withStills = { ...settings, studio: { ...studio, shots } }
130
+
131
+ const out = studioOf(stripStudioTransientSettings(withStills))
132
+ expect((out.shots as Array<Record<string, unknown>>)[0].pendingStills).toBeUndefined()
133
+ })
134
+
135
+ it("drops a legacy single `pendingClip` too", () => {
136
+ // Pre-concurrent-markers saves wrote one marker under the singular key; the
137
+ // reader still migrates it, so it is still in-flight state a viewer must
138
+ // not receive.
139
+ const settings = written()
140
+ const studio = studioOf(settings)
141
+ const shots = [{ id: "s2", pendingClip: PENDING }]
142
+ const legacy = { ...settings, studio: { ...studio, shots } }
143
+
144
+ const out = studioOf(stripStudioTransientSettings(legacy))
145
+ expect((out.shots as Array<Record<string, unknown>>)[0]).toEqual({ id: "s2" })
146
+ })
147
+
148
+ it("leaves a workflow that is not a production alone", () => {
149
+ const settings = { presentationSettings: { shareReadOnly: true } }
150
+ expect(stripStudioTransientSettings(settings)).toBe(settings)
151
+ expect(stripStudioTransientSettings(null)).toBeNull()
152
+ expect(stripStudioTransientSettings(undefined)).toBeUndefined()
153
+ })
154
+
155
+ it("survives a document whose shots are not what the editor writes", () => {
156
+ // The strip runs on whatever is in the column, including a row written by
157
+ // something that is not the studio editor. It must project, never throw.
158
+ const odd = { studio: { version: 3, shots: ["nonsense", null, 7] } }
159
+ expect(() => stripStudioTransientSettings(odd)).not.toThrow()
160
+ expect(studioOf(stripStudioTransientSettings(odd)).shots).toEqual(["nonsense", null, 7])
161
+ })
162
+
163
+ it("drops the DOCUMENT's own pendingMusic and pendingDraft (D5)", () => {
164
+ // The document-level twin of the per-shot pair: a soundtrack render and a
165
+ // Director run in flight. Same rule — they name jobs on the owner's
166
+ // account, and no viewer can read or land one.
167
+ const settings = written()
168
+ const studio = studioOf(settings)
169
+ const inFlight = {
170
+ ...settings,
171
+ studio: {
172
+ ...studio,
173
+ pendingMusic: { jobId: "job-3", startedAt: 1_756_000_000_000 },
174
+ pendingDraft: { jobId: "job-4", startedAt: 1_756_000_000_000 },
175
+ },
176
+ }
177
+ // The oracle: the document really does carry both before the public read.
178
+ expect(studioOf(inFlight).pendingMusic).toBeDefined()
179
+ expect(studioOf(inFlight).pendingDraft).toBeDefined()
180
+
181
+ const out = studioOf(stripStudioTransientSettings(inFlight))
182
+ expect(out.pendingMusic).toBeUndefined()
183
+ expect(out.pendingDraft).toBeUndefined()
184
+ // ...and the film is still there.
185
+ expect(out.version).toBe(3)
186
+ })
187
+
188
+ it("pins the two lists — a key added to the type alone strips nothing", () => {
189
+ // The lists are the contract: the codec's own strip re-exports them, so a
190
+ // key that falls off here falls off there too, silently, on both sides.
191
+ expect([...STUDIO_TRANSIENT_KEYS]).toEqual([
192
+ "trash",
193
+ "pendingStills",
194
+ "pendingClips",
195
+ "pendingMusic",
196
+ "pendingDraft",
197
+ "freecutDraftUrl",
198
+ ])
199
+ expect([...STUDIO_SHOT_TRANSIENT_KEYS]).toEqual([
200
+ "pendingClips",
201
+ "pendingClip",
202
+ "pendingStills",
203
+ ])
204
+ })
205
+ })
package/src/index.ts CHANGED
@@ -1052,11 +1052,9 @@ export {
1052
1052
  export { ENTITY_NODE_KINDS } from "./entity-node-fields.js"
1053
1053
  export type { EntityNodeKind } from "./entity-node-fields.js"
1054
1054
 
1055
- // --- wire contract of /v1/studio/productions — types only ---
1056
- // The studio production CODEC (the reader/writer of `settings.studio`, the
1057
- // plan format, the catalogs and the reducers) lives in the FSL-licensed
1058
- // `@nodaro/studio-production`. What crosses into this Apache package is the
1059
- // envelope those routes return and the bodies they take, so the SDK can be
1060
- // typed against it — the document's own sub-objects stay named JSON here and
1061
- // are narrowed, and pinned, by the package that owns them.
1062
- export type * from "./studio-production-wire.js"
1055
+ // --- transient studio keys — the public share read strips them ---
1056
+ export {
1057
+ STUDIO_TRANSIENT_KEYS,
1058
+ STUDIO_SHOT_TRANSIENT_KEYS,
1059
+ stripStudioTransientSettings,
1060
+ } from "./studio-transient.js"
@@ -0,0 +1,124 @@
1
+ /**
2
+ * The parts of `settings.studio` that must not leave the owner's account.
3
+ *
4
+ * A shared production is read by anyone with the link. Three things inside the
5
+ * document are the OWNER'S working state and nobody else's business:
6
+ *
7
+ * - `trash` — the recycle bin, which holds every shot, still and clip they
8
+ * deleted, with prompts and urls intact. A share viewer receiving the bin is
9
+ * the sharpest of the three: it hands out work the owner explicitly threw away.
10
+ * - the in-flight job markers — `pendingClips` / `pendingStills` per shot, and
11
+ * `pendingMusic` / `pendingDraft` on the document. A viewer cannot land any
12
+ * of them and does not own them; all they carry across is job ids.
13
+ * - `freecutDraftUrl` — an unsaved editor draft.
14
+ *
15
+ * They do NOT all live at the same level, and that is the whole reason this
16
+ * file exists rather than one array: the writer puts `trash` and
17
+ * `freecutDraftUrl` on `settings.studio` itself, and puts the per-shot markers
18
+ * on the `settings.studio.shots[]` entry. A strip that walked only the top
19
+ * level would pass its own test and still hand a share viewer every marker in
20
+ * the production.
21
+ *
22
+ * It lives in `@nodaro/shared` because two independent readers need the SAME
23
+ * list: the public share read (which is the reason the list exists) and the
24
+ * production writer's own bundle projection. A second copy of a list like this
25
+ * does not stay equal — it goes one key stale and the stale side is the one
26
+ * that publishes.
27
+ *
28
+ * This is a plain JSON walker on purpose. `settings` is a free-form column that
29
+ * a client owns end to end; the projection reads the keys it must drop and
30
+ * nothing else, so it never needs — and must never grow — a dependency on
31
+ * whatever writes the rest of the document.
32
+ */
33
+
34
+ /**
35
+ * `settings.studio`'s OWN transient keys.
36
+ *
37
+ * The per-shot pending lists are on this list as well as the shot one on
38
+ * purpose: nothing writes them here today, and a stray one from an older
39
+ * client — or from a client that is not the studio editor at all — still must
40
+ * not ride out to a viewer.
41
+ */
42
+ export const STUDIO_TRANSIENT_KEYS = [
43
+ "trash",
44
+ "pendingStills",
45
+ "pendingClips",
46
+ // The two markers that genuinely DO live at this level: a soundtrack render
47
+ // and a story-planning run in flight. Same rule as the per-shot pair — they
48
+ // name jobs on the owner's account and nobody else can land them.
49
+ "pendingMusic",
50
+ "pendingDraft",
51
+ "freecutDraftUrl",
52
+ ] as const
53
+
54
+ /**
55
+ * ...and a SHOT entry's, which is where the per-shot markers actually are.
56
+ *
57
+ * `pendingClip` (singular) is the pre-concurrent-markers shape; the editor's
58
+ * reader still migrates it on parse, so a row can still be carrying one and it
59
+ * is still in-flight state.
60
+ */
61
+ export const STUDIO_SHOT_TRANSIENT_KEYS = ["pendingClips", "pendingClip", "pendingStills"] as const
62
+
63
+ function withoutKeys(
64
+ source: Record<string, unknown>,
65
+ drop: ReadonlyArray<string>,
66
+ ): Record<string, unknown> {
67
+ const kept: Record<string, unknown> = {}
68
+ for (const [key, value] of Object.entries(source)) {
69
+ if (drop.includes(key)) continue
70
+ kept[key] = value
71
+ }
72
+ return kept
73
+ }
74
+
75
+ /**
76
+ * `settings.studio.shots` with every shot's in-flight markers removed.
77
+ *
78
+ * Returns the SAME array when no shot carried one, so an idle production's
79
+ * share read allocates nothing. Anything that is not a shot-shaped object rides
80
+ * through untouched: this runs on whatever is in the column, and a projection
81
+ * that threw on an unexpected row would take the share read down with it.
82
+ */
83
+ function stripShots(value: unknown): unknown {
84
+ if (!Array.isArray(value)) return value
85
+ let changed = false
86
+ const out = value.map((entry) => {
87
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return entry
88
+ const shot = entry as Record<string, unknown>
89
+ if (!STUDIO_SHOT_TRANSIENT_KEYS.some((key) => key in shot)) return entry
90
+ changed = true
91
+ return withoutKeys(shot, STUDIO_SHOT_TRANSIENT_KEYS)
92
+ })
93
+ return changed ? out : value
94
+ }
95
+
96
+ /**
97
+ * A production's `settings` with the owner's working state removed.
98
+ *
99
+ * Copy-on-write, and structurally: it rebuilds the objects without those keys
100
+ * rather than deleting from the caller's, so the stored row is untouched. A
101
+ * `settings` with no `studio` comes back unchanged — this is a studio concern,
102
+ * and a workflow that is not a production has nothing here to strip.
103
+ *
104
+ * Takes and returns `unknown` because the column is free-form and every caller
105
+ * already holds it as whatever its own layer calls JSON; narrowing here would
106
+ * only move the cast one line up.
107
+ */
108
+ export function stripStudioTransientSettings(settings: unknown): unknown {
109
+ if (!settings || typeof settings !== "object") return settings
110
+ const studio = (settings as { studio?: unknown }).studio
111
+ if (!studio || typeof studio !== "object" || Array.isArray(studio)) return settings
112
+
113
+ const source = studio as Record<string, unknown>
114
+ const kept = withoutKeys(source, STUDIO_TRANSIENT_KEYS)
115
+ const shots = stripShots(source.shots)
116
+ if (source.shots !== undefined) kept.shots = shots
117
+
118
+ // Nothing to drop at either level — hand back the original object so an
119
+ // ordinary share read allocates nothing.
120
+ if (Object.keys(kept).length === Object.keys(source).length && shots === source.shots) {
121
+ return settings
122
+ }
123
+ return { ...(settings as Record<string, unknown>), studio: kept }
124
+ }