@rohal12/spindle 0.51.3 → 0.51.4

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.
@@ -231,9 +231,13 @@ function parseHtmlAttributes(
231
231
  const attrName = input.slice(attrStart, j);
232
232
  if (!attrName) break;
233
233
 
234
- // Check for = value
235
- if (input[j] === '=') {
236
- j++; // skip =
234
+ // Check for = value. HTML allows whitespace on either side of the =
235
+ // (`id = "x"`); whitespace not followed by = ends a boolean attribute.
236
+ let eqIdx = j;
237
+ while (eqIdx < input.length && /\s/.test(input[eqIdx]!)) eqIdx++;
238
+ if (input[eqIdx] === '=') {
239
+ j = eqIdx + 1; // skip =
240
+ while (j < input.length && /\s/.test(input[j]!)) j++;
237
241
  if (input[j] === '"' || input[j] === "'") {
238
242
  const quote = input[j]!;
239
243
  j++; // skip opening quote
@@ -395,12 +399,22 @@ export function tokenize(input: string): Token[] {
395
399
  }
396
400
 
397
401
  while (i < input.length) {
398
- // Handle escaped braces: \{ and \}
399
- if (input[i] === '\\' && (input[i + 1] === '{' || input[i + 1] === '}')) {
400
- flushText(i);
401
- tokens.push({ type: 'text', value: input[i + 1]!, start: i, end: i + 2 });
402
- i += 2;
403
- textStart = i;
402
+ // Escaped braces: \{ and \}. Count the whole backslash run so \\{ is a
403
+ // backslash pair before a live brace. In an odd run the last backslash
404
+ // escapes the brace; the even rest stays text, which markdown collapses
405
+ // pair by pair like any other \\ in the passage.
406
+ if (input[i] === '\\') {
407
+ let k = i + 1;
408
+ while (input[k] === '\\') k++;
409
+ const next = input[k];
410
+ if ((next === '{' || next === '}') && (k - i) % 2 === 1) {
411
+ flushText(k - 1);
412
+ tokens.push({ type: 'text', value: next, start: k - 1, end: k + 1 });
413
+ i = k + 1;
414
+ textStart = i;
415
+ continue;
416
+ }
417
+ i = k;
404
418
  continue;
405
419
  }
406
420
 
@@ -71,12 +71,19 @@ export async function initSaveSystem(): Promise<void> {
71
71
 
72
72
  // --- Playthroughs ---
73
73
 
74
- export async function startNewPlaythrough(ifid: string): Promise<string> {
74
+ /**
75
+ * Store a new playthrough and make it the story's current one. Pass `id` when
76
+ * the caller has already switched to the playthrough (restart() does, so
77
+ * saves issued before this resolves are tagged with it).
78
+ */
79
+ export async function startNewPlaythrough(
80
+ ifid: string,
81
+ id: string = crypto.randomUUID(),
82
+ ): Promise<string> {
75
83
  const backend = await getBackend();
76
84
  const existing = await backend.getPlaythroughsByIfid(ifid);
77
85
  const num = existing.length + 1;
78
86
 
79
- const id = crypto.randomUUID();
80
87
  const record: PlaythroughRecord = {
81
88
  id,
82
89
  ifid,
@@ -162,10 +169,26 @@ export async function createSave(
162
169
  return record;
163
170
  }
164
171
 
172
+ /**
173
+ * Replace the payload of an existing save, keeping its ID and `createdAt`.
174
+ * Pass the current `playthroughId` when the new payload comes from the
175
+ * running game: the save then holds that playthrough's state, so it is
176
+ * grouped and deleted with it rather than with the playthrough that first
177
+ * created the save.
178
+ *
179
+ * Metadata describing the content follows the new payload: `passage`,
180
+ * `updatedAt` and the title. A generated title (SaveTitle passage, title
181
+ * generator or the default "passage - time") is generated again, exactly as
182
+ * for a fresh save, so it never names a passage or state the save no longer
183
+ * holds. A title the player gave the save (renameSave) is kept: it names the
184
+ * save itself rather than describing its content. `custom` is merged, as
185
+ * before, so slot keys and metadata not passed again are kept.
186
+ */
165
187
  export async function overwriteSave(
166
188
  saveId: string,
167
189
  payload: SavePayload,
168
190
  custom?: Record<string, unknown>,
191
+ playthroughId?: string,
169
192
  ): Promise<SaveRecord | undefined> {
170
193
  const backend = await getBackend();
171
194
  const existing = await backend.getSave(saveId);
@@ -180,7 +203,12 @@ export async function overwriteSave(
180
203
  const updated: SaveRecord = {
181
204
  meta: {
182
205
  ...existing.meta,
206
+ ...(playthroughId ? { playthroughId } : {}),
183
207
  updatedAt: new Date().toISOString(),
208
+ title:
209
+ existing.meta.userTitle === true
210
+ ? existing.meta.title
211
+ : generateTitle(payload),
184
212
  passage: payload.passage,
185
213
  ...(custom != null
186
214
  ? { custom: { ...existing.meta.custom, ...custom } }
@@ -251,6 +279,7 @@ export async function renameSave(
251
279
  meta: {
252
280
  ...record.meta,
253
281
  title: newTitle,
282
+ userTitle: true,
254
283
  updatedAt: new Date().toISOString(),
255
284
  },
256
285
  };
@@ -431,7 +460,12 @@ export function quickSave(
431
460
  const existingId = await backend.getMeta<string>(metaKey);
432
461
 
433
462
  if (existingId) {
434
- const updated = await overwriteSave(existingId, payload, custom);
463
+ const updated = await overwriteSave(
464
+ existingId,
465
+ payload,
466
+ custom,
467
+ playthroughId,
468
+ );
435
469
  if (updated) return updated;
436
470
  }
437
471
 
@@ -154,6 +154,14 @@ export function createLocalStorageBackend(): StorageBackend {
154
154
 
155
155
  async putSave(record: SaveRecord): Promise<void> {
156
156
  const id = record.meta.id;
157
+ // A save rewritten under another playthrough (an overwrite after a
158
+ // restart) leaves its old playthrough's index
159
+ const previous = lsGet<SaveRecord>(`${LS_SAVE_PREFIX}${id}`);
160
+ if (previous && previous.meta.playthroughId !== record.meta.playthroughId)
161
+ lsIndexRemove(
162
+ `${LS_IDX_SAVES_PT_PREFIX}${previous.meta.playthroughId}`,
163
+ id,
164
+ );
157
165
  lsSet(`${LS_SAVE_PREFIX}${id}`, record);
158
166
  lsIndexAdd(`${LS_IDX_SAVES_PREFIX}${record.meta.ifid}`, id);
159
167
  lsIndexAdd(`${LS_IDX_SAVES_PT_PREFIX}${record.meta.playthroughId}`, id);
@@ -1,4 +1,5 @@
1
1
  import type { PRNGSnapshot } from '../prng';
2
+ import { isDeserializable } from '../class-registry';
2
3
 
3
4
  /** History moment as persisted in saves (full variable snapshots). */
4
5
  export interface SaveHistoryMoment {
@@ -25,6 +26,11 @@ export interface SaveMeta {
25
26
  createdAt: string;
26
27
  updatedAt: string;
27
28
  title: string;
29
+ /**
30
+ * True once the player has named the save (renameSave). Overwriting the
31
+ * save keeps such a title; a generated one is generated anew.
32
+ */
33
+ userTitle?: boolean;
28
34
  passage: string;
29
35
  custom: Record<string, unknown>;
30
36
  estimatedBytes?: number;
@@ -97,11 +103,16 @@ function isOptionalPRNGSnapshot(value: unknown): boolean {
97
103
  );
98
104
  }
99
105
 
106
+ /** A variable container whose values deserialize() can restore. */
107
+ function isSerializedVariables(value: unknown): boolean {
108
+ return isRecord(value) && isDeserializable(value);
109
+ }
110
+
100
111
  function isSaveHistoryMoment(value: unknown): value is SaveHistoryMoment {
101
112
  return (
102
113
  isRecord(value) &&
103
114
  typeof value.passage === 'string' &&
104
- isRecord(value.variables) &&
115
+ isSerializedVariables(value.variables) &&
105
116
  typeof value.timestamp === 'number' &&
106
117
  isOptionalPRNGSnapshot(value.prng)
107
118
  );
@@ -109,14 +120,16 @@ function isSaveHistoryMoment(value: unknown): value is SaveHistoryMoment {
109
120
 
110
121
  /**
111
122
  * Full check of a payload from outside the running story (an imported save):
112
- * every history moment is well formed, and `historyIndex` is an integer that
113
- * points at a moment of the payload's passage. Anything that passes can be
114
- * stored and later loaded.
123
+ * every history moment is well formed, `historyIndex` is an integer that
124
+ * points at a moment of the payload's passage, and the encoded values (Map,
125
+ * Set, Date, RegExp, class instances) in the live variables and every
126
+ * moment's variables have the shape deserialize() expects. Anything that
127
+ * passes can be stored and later loaded.
115
128
  */
116
129
  function isImportablePayload(value: unknown): value is SavePayload {
117
130
  if (!isRecord(value)) return false;
118
131
  if (typeof value.passage !== 'string') return false;
119
- if (!isRecord(value.variables)) return false;
132
+ if (!isSerializedVariables(value.variables)) return false;
120
133
 
121
134
  const { history, historyIndex } = value;
122
135
  if (!Array.isArray(history) || history.length === 0) return false;
package/src/store.ts CHANGED
@@ -234,6 +234,34 @@ export function recordStoryInitState(): void {
234
234
  });
235
235
  }
236
236
 
237
+ // ---------------------------------------------------------------------------
238
+ // Playthrough setup
239
+ // ---------------------------------------------------------------------------
240
+
241
+ /**
242
+ * Settles once the latest playthrough setup (init's lookup or creation, or a
243
+ * restart's creation) is stored, with that setup's playthrough ID ('' if
244
+ * init could not establish one). Each setup chains on the previous one, so
245
+ * playthroughs are created and numbered in the order the game started them.
246
+ */
247
+ let playthroughSetup: Promise<string> = Promise.resolve('');
248
+
249
+ /** Bumped by every init()/restart(); a stale init must not adopt its ID. */
250
+ let playthroughGeneration = 0;
251
+
252
+ /**
253
+ * The playthrough a save issued now belongs to, once its record is stored.
254
+ * Read synchronously at the call: restart() switches the store's
255
+ * `playthroughId` at once, so a save issued after it (even before the new
256
+ * playthrough is stored) belongs to the new playthrough, and a later restart
257
+ * doesn't move it. Before init() has looked up the stored playthrough the
258
+ * store's ID is '', and the save takes the one init establishes.
259
+ */
260
+ export function resolvePlaythroughId(): Promise<string> {
261
+ const current = useStoryStore.getState().playthroughId;
262
+ return playthroughSetup.then((established) => current || established);
263
+ }
264
+
237
265
  // ---------------------------------------------------------------------------
238
266
  // Runtime handler cleanup (auto-unsub on restart)
239
267
  // ---------------------------------------------------------------------------
@@ -502,6 +530,8 @@ export const useStoryStore = create<StoryState>()(
502
530
 
503
531
  set((state) => {
504
532
  state.storyData = storyData as StoryData;
533
+ // Unknown until the save system has looked it up (below)
534
+ state.playthroughId = '';
505
535
  state.currentPassage = startPassage.name;
506
536
  state.navigationId++;
507
537
  state.variables = initialVars;
@@ -523,19 +553,20 @@ export const useStoryStore = create<StoryState>()(
523
553
  // Update lastNavigationVars to the Immer-produced reference
524
554
  lastNavigationVars = get().variables;
525
555
 
526
- // Init save system (fire-and-forget — DB will be ready before user opens dialog)
556
+ // Init save system in the background. Saves issued meanwhile wait for
557
+ // it (see resolvePlaythroughId), so they are tagged with the
558
+ // playthrough it establishes and recorded after the known saves.
527
559
  const ifid = storyData.ifid;
528
- initSaveSystem()
560
+ const generation = ++playthroughGeneration;
561
+ playthroughSetup = initSaveSystem()
529
562
  .then(async () => {
530
- const existingId = await getCurrentPlaythroughId(ifid);
531
- if (existingId) {
563
+ const id =
564
+ (await getCurrentPlaythroughId(ifid)) ??
565
+ (await startNewPlaythrough(ifid));
566
+ // A restart issued meanwhile has already switched playthroughs
567
+ if (generation === playthroughGeneration) {
532
568
  set((state) => {
533
- state.playthroughId = existingId;
534
- });
535
- } else {
536
- const newId = await startNewPlaythrough(ifid);
537
- set((state) => {
538
- state.playthroughId = newId;
569
+ state.playthroughId = id;
539
570
  });
540
571
  }
541
572
 
@@ -546,10 +577,12 @@ export const useStoryStore = create<StoryState>()(
546
577
  state.knownSaves = saves;
547
578
  });
548
579
  }
580
+ return id;
549
581
  })
550
- .catch((err) =>
551
- console.error('spindle: failed to init save system', err),
552
- );
582
+ .catch((err) => {
583
+ console.error('spindle: failed to init save system', err);
584
+ return '';
585
+ });
553
586
  },
554
587
 
555
588
  navigate: (passageName: string) => {
@@ -774,6 +807,26 @@ export const useStoryStore = create<StoryState>()(
774
807
 
775
808
  emit('beforerestart');
776
809
 
810
+ // Switch to the new playthrough now, after beforerestart (whose saves
811
+ // belong to the game being left) and before StoryInit, so every save
812
+ // issued from here on belongs to the new game. Storing its record is
813
+ // queued after the previous playthrough setup.
814
+ const newPlaythroughId = crypto.randomUUID();
815
+ ++playthroughGeneration;
816
+ set((state) => {
817
+ state.playthroughId = newPlaythroughId;
818
+ });
819
+ const ifid = storyData.ifid;
820
+ playthroughSetup = playthroughSetup
821
+ .then(() => startNewPlaythrough(ifid, newPlaythroughId))
822
+ .then(
823
+ () => newPlaythroughId,
824
+ (err) => {
825
+ console.error('spindle: failed to start new playthrough', err);
826
+ return newPlaythroughId;
827
+ },
828
+ );
829
+
777
830
  const keepDeferred = get().renderDeferred;
778
831
 
779
832
  // Clean up all runtime-phase handlers (after beforerestart has fired)
@@ -814,28 +867,20 @@ export const useStoryStore = create<StoryState>()(
814
867
  emit('storyinit');
815
868
  // The storyinit handlers' changes belong to the start moment too
816
869
  recordStoryInitState();
817
-
818
- // Start a new playthrough on restart
819
- startNewPlaythrough(storyData.ifid)
820
- .then((newId) => {
821
- set((state) => {
822
- state.playthroughId = newId;
823
- });
824
- })
825
- .catch((err) =>
826
- console.error('spindle: failed to start new playthrough', err),
827
- );
828
870
  },
829
871
 
830
872
  save: (slot?: string, custom?: Record<string, unknown>) => {
831
- const { storyData, playthroughId } = get();
873
+ const { storyData } = get();
832
874
  if (!storyData) return Promise.resolve();
875
+ // The playthrough current now, not when the write runs
876
+ const playthrough = resolvePlaythroughId();
833
877
 
834
878
  return handled(
835
879
  saveWithHooks(slot, custom, get().beginSave, async (payload) => {
836
880
  set((state) => {
837
881
  state.saveError = null;
838
882
  });
883
+ const playthroughId = await playthrough;
839
884
  await quickSave(storyData.ifid, playthroughId, payload, slot, custom);
840
885
  set((state) => {
841
886
  state.knownSaves = {
package/src/story-api.ts CHANGED
@@ -25,6 +25,13 @@ import {
25
25
  } from './saves/save-manager';
26
26
  import { getBackendType } from './saves/storage';
27
27
  import { registerClass } from './class-registry';
28
+ import {
29
+ frozenCopy,
30
+ getActiveMutationScope,
31
+ mirrorWriteToActiveScopes,
32
+ runWithCommittedMutations,
33
+ } from './execute-mutation';
34
+ import { getByPath, setByPath } from './utils/object-path';
28
35
  import { defineMacro } from './define-macro';
29
36
  import type { MacroDefinition } from './define-macro';
30
37
  import { getMacroRegistry as _getMacroRegistry } from './registry';
@@ -116,37 +123,6 @@ function ensureVariableChangedSubscription(): void {
116
123
  });
117
124
  }
118
125
 
119
- /** Traverse a dot-delimited path on an object and return the value. */
120
- function getByPath(obj: Record<string, unknown>, path: string): unknown {
121
- const segments = path.split('.');
122
- let current: unknown = obj[segments[0]!];
123
- for (let i = 1; i < segments.length; i++) {
124
- if (current == null) return undefined;
125
- current = (current as Record<string, unknown>)[segments[i]!];
126
- }
127
- return current;
128
- }
129
-
130
- /** Set a value at a dot-delimited path on an object (must be an Immer draft for mutation). */
131
- function setByPath(
132
- obj: Record<string, unknown>,
133
- path: string,
134
- value: unknown,
135
- ): void {
136
- const segments = path.split('.');
137
- let current: Record<string, unknown> = obj;
138
- for (let i = 0; i < segments.length - 1; i++) {
139
- const next = current[segments[i]!];
140
- if (next == null || typeof next !== 'object') {
141
- throw new TypeError(
142
- `spindle: Cannot set property "${segments[i + 1]}" on ${typeof next} (at "${segments.slice(0, i + 1).join('.')}")`,
143
- );
144
- }
145
- current = next as Record<string, unknown>;
146
- }
147
- current[segments[segments.length - 1]!] = value;
148
- }
149
-
150
126
  export interface StoryAPI {
151
127
  get(name: string): unknown;
152
128
  set(name: string, value: unknown): void;
@@ -292,7 +268,7 @@ function setOne(draft: VariableNamespaces, name: string, value: unknown): void {
292
268
  const namespace = isTransient ? draft.transient : draft.variables;
293
269
 
294
270
  if (key.includes('.')) {
295
- setByPath(namespace, key, value);
271
+ setByPath(namespace, key.split('.'), value);
296
272
  } else {
297
273
  namespace[key] = value;
298
274
  }
@@ -302,49 +278,74 @@ function createStoryAPI(): StoryAPI {
302
278
  return {
303
279
  get(name: string): unknown {
304
280
  const { isTransient, key } = parseName(name);
305
- const store = isTransient
306
- ? useStoryStore.getState().transient
307
- : useStoryStore.getState().variables;
308
- return key.includes('.') ? getByPath(store, key) : store[key];
281
+ // Mutation code running now ({do}, ctx.mutate, watcher run actions)
282
+ // has pending writes in its working copy: read that, so the code sees
283
+ // its own changes. The value is a frozen copy, like the frozen store
284
+ // values returned otherwise, so writing to it cannot change the
285
+ // pending state outside the code's own assignments.
286
+ const scope = getActiveMutationScope();
287
+ const source = scope ?? useStoryStore.getState();
288
+ const namespace = isTransient ? source.transient : source.variables;
289
+ const value = key.includes('.')
290
+ ? getByPath(namespace, key.split('.'))
291
+ : namespace[key];
292
+ return scope ? frozenCopy(value) : value;
309
293
  },
310
294
 
311
295
  set(nameOrVars: string | Record<string, unknown>, value?: unknown): void {
312
- const names =
313
- typeof nameOrVars === 'string' ? [nameOrVars] : Object.keys(nameOrVars);
314
- for (const name of names) {
296
+ const entries: [string, unknown][] =
297
+ typeof nameOrVars === 'string'
298
+ ? [[nameOrVars, value]]
299
+ : Object.entries(nameOrVars);
300
+ for (const [name] of entries) {
315
301
  const { isTransient, key } = parseName(name);
316
302
  warnIfUndeclared(isTransient, key);
317
303
  }
318
304
  // One store update for all keys, so watchers see them together
319
305
  useStoryStore.getState().updateVariables((draft) => {
320
- if (typeof nameOrVars === 'string') {
321
- setOne(draft, nameOrVars, value);
322
- } else {
323
- for (const [k, v] of Object.entries(nameOrVars)) {
324
- setOne(draft, k, v);
325
- }
306
+ for (const [k, v] of entries) setOne(draft, k, v);
307
+ // Mutation code running now ({do}, ctx.mutate, watcher run actions)
308
+ // works on copies of the namespaces and commits the paths it changed
309
+ // when it finishes. Apply the write to those copies too, so the code
310
+ // sees it and its commit keeps it in program order (#215). This runs
311
+ // inside the update, before watchers it triggers write.
312
+ for (const [k, v] of entries) {
313
+ const { isTransient, key } = parseName(k);
314
+ mirrorWriteToActiveScopes(
315
+ draft,
316
+ isTransient ? 'transient' : 'variables',
317
+ key.split('.'),
318
+ v,
319
+ );
326
320
  }
327
321
  });
328
322
  },
329
323
 
324
+ // Called from running mutation code, these commit the code's writes so
325
+ // far before they record, replace or save state, and the code goes on
326
+ // from the state they leave (see runWithCommittedMutations).
330
327
  goto(passageName: string): void {
331
- useStoryStore.getState().navigate(passageName);
328
+ runWithCommittedMutations(() =>
329
+ useStoryStore.getState().navigate(passageName),
330
+ );
332
331
  },
333
332
 
334
333
  back(): void {
335
- useStoryStore.getState().goBack();
334
+ runWithCommittedMutations(() => useStoryStore.getState().goBack());
336
335
  },
337
336
 
338
337
  forward(): void {
339
- useStoryStore.getState().goForward();
338
+ runWithCommittedMutations(() => useStoryStore.getState().goForward());
340
339
  },
341
340
 
342
341
  restart(): void {
343
- useStoryStore.getState().restart();
342
+ runWithCommittedMutations(() => useStoryStore.getState().restart());
344
343
  },
345
344
 
346
345
  save(slot?: string, custom?: Record<string, unknown>): Promise<void> {
347
- return useStoryStore.getState().save(slot, custom);
346
+ return runWithCommittedMutations(() =>
347
+ useStoryStore.getState().save(slot, custom),
348
+ );
348
349
  },
349
350
 
350
351
  load(slot?: string): Promise<void> {
package/src/triggers.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { evaluate } from './expression';
2
- import { executeMutation } from './execute-mutation';
2
+ import { executeMutation, runWithCommittedMutations } from './execute-mutation';
3
3
  import { useStoryStore } from './store';
4
4
 
5
5
  export interface WatchOptions {
@@ -149,7 +149,10 @@ function fireTrigger(trigger: Trigger): void {
149
149
  }
150
150
 
151
151
  if (options.goto) {
152
- useStoryStore.getState().navigate(options.goto);
152
+ // A watcher fired by a Story.set in running code navigates after that
153
+ // code's writes so far, so they are part of the moment it leaves.
154
+ const passage = options.goto;
155
+ runWithCommittedMutations(() => useStoryStore.getState().navigate(passage));
153
156
  }
154
157
  }
155
158
 
@@ -0,0 +1,109 @@
1
+ import { isDraft } from 'immer';
2
+
3
+ /** Traverse dot-path segments on an object and return the nested value. */
4
+ export function getByPath(
5
+ obj: Record<string, unknown>,
6
+ segments: readonly string[],
7
+ ): unknown {
8
+ let current: unknown = obj;
9
+ for (const seg of segments) {
10
+ if (current == null || typeof current !== 'object') return undefined;
11
+ current = (current as Record<string, unknown>)[seg];
12
+ }
13
+ return current;
14
+ }
15
+
16
+ /**
17
+ * Shallow copy that keeps the prototype, so a registered class instance
18
+ * stays an instance of its class (with the same own keys deepClone copies).
19
+ */
20
+ function shallowCopy(value: object): Record<string, unknown> {
21
+ let copy: object;
22
+ if (Array.isArray(value)) copy = [];
23
+ else if (value instanceof Map) copy = new Map(value);
24
+ else if (value instanceof Set) copy = new Set(value);
25
+ else if (value instanceof Date) copy = new Date(value.getTime());
26
+ else copy = Object.create(Object.getPrototypeOf(value)) as object;
27
+ return Object.assign(copy, value) as Record<string, unknown>;
28
+ }
29
+
30
+ export interface SetByPathOptions {
31
+ /**
32
+ * Create a plain object for a missing or non-object intermediate instead
33
+ * of throwing.
34
+ */
35
+ createMissing?: boolean;
36
+ }
37
+
38
+ /**
39
+ * Set a value at dot-path `segments` below `root`.
40
+ *
41
+ * When `root` is an Immer draft, objects Immer does not draft (registered
42
+ * class instances and everything below them) are copied on the way down and
43
+ * the copy is assigned to its parent, so the write produces new references
44
+ * up to the root. Writing into such an object directly would mutate the
45
+ * instance shared with the previous state and with history, and subscribers
46
+ * would see no change. Outside a draft (a private working copy) the path is
47
+ * written in place.
48
+ */
49
+ export function setByPath(
50
+ root: Record<string, unknown>,
51
+ segments: readonly string[],
52
+ value: unknown,
53
+ options: SetByPathOptions = {},
54
+ ): void {
55
+ const parent = walkToParent(root, segments, options.createMissing ?? false);
56
+ parent[segments[segments.length - 1]!] = value;
57
+ }
58
+
59
+ /**
60
+ * Delete the property at dot-path `segments` below `root`, copying
61
+ * undrafted objects on the way down like setByPath(). Does nothing when an
62
+ * intermediate is missing or not an object, or the property is absent.
63
+ */
64
+ export function deleteByPath(
65
+ root: Record<string, unknown>,
66
+ segments: readonly string[],
67
+ ): void {
68
+ const last = segments[segments.length - 1]!;
69
+ // Check first, so a no-op delete copies nothing.
70
+ const holder = getByPath(root, segments.slice(0, -1));
71
+ if (holder == null || typeof holder !== 'object' || !(last in holder)) {
72
+ return;
73
+ }
74
+ const parent = walkToParent(root, segments, false);
75
+ delete parent[last];
76
+ }
77
+
78
+ /**
79
+ * Walk to the object holding the last of `segments`, copying undrafted
80
+ * objects when `root` is an Immer draft (see setByPath). A missing or
81
+ * non-object intermediate is replaced by a plain object when
82
+ * `createMissing` is set, and throws a TypeError otherwise.
83
+ */
84
+ function walkToParent(
85
+ root: Record<string, unknown>,
86
+ segments: readonly string[],
87
+ createMissing: boolean,
88
+ ): Record<string, unknown> {
89
+ const copyOnWrite = isDraft(root);
90
+ let current: Record<string, unknown> = root;
91
+ for (let i = 0; i < segments.length - 1; i++) {
92
+ const seg = segments[i]!;
93
+ let next = current[seg];
94
+ if (next == null || typeof next !== 'object') {
95
+ if (!createMissing) {
96
+ throw new TypeError(
97
+ `spindle: Cannot set property "${segments[i + 1]}" on ${next === null ? 'null' : typeof next} (at "${segments.slice(0, i + 1).join('.')}")`,
98
+ );
99
+ }
100
+ next = {};
101
+ current[seg] = next;
102
+ } else if (copyOnWrite && !isDraft(next)) {
103
+ next = shallowCopy(next);
104
+ current[seg] = next;
105
+ }
106
+ current = next as Record<string, unknown>;
107
+ }
108
+ return current;
109
+ }