@brass-build/client 0.2.0 → 0.3.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.
Files changed (83) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +16 -12
  3. package/dist/client.d.ts +1 -0
  4. package/dist/client.d.ts.map +1 -1
  5. package/dist/client.js +28 -1
  6. package/dist/client.js.map +1 -1
  7. package/dist/document-checkpoints.d.ts +1 -0
  8. package/dist/document-checkpoints.d.ts.map +1 -1
  9. package/dist/document-checkpoints.js +27 -0
  10. package/dist/document-checkpoints.js.map +1 -1
  11. package/dist/document-signals.d.ts +31 -1
  12. package/dist/document-signals.d.ts.map +1 -1
  13. package/dist/document-signals.js +59 -0
  14. package/dist/document-signals.js.map +1 -1
  15. package/dist/document-thumbnail-capture.d.ts +20 -0
  16. package/dist/document-thumbnail-capture.d.ts.map +1 -1
  17. package/dist/document-thumbnail-capture.js +247 -9
  18. package/dist/document-thumbnail-capture.js.map +1 -1
  19. package/dist/documents-namespace.d.ts.map +1 -1
  20. package/dist/documents-namespace.js +3 -0
  21. package/dist/documents-namespace.js.map +1 -1
  22. package/dist/documents.d.ts +6 -2
  23. package/dist/documents.d.ts.map +1 -1
  24. package/dist/documents.js +110 -14
  25. package/dist/documents.js.map +1 -1
  26. package/dist/handles.d.ts.map +1 -1
  27. package/dist/handles.js +2 -2
  28. package/dist/handles.js.map +1 -1
  29. package/dist/importer.d.ts.map +1 -1
  30. package/dist/importer.js +11 -60
  31. package/dist/importer.js.map +1 -1
  32. package/dist/internal.d.ts +1 -0
  33. package/dist/internal.d.ts.map +1 -1
  34. package/dist/internal.js +1 -0
  35. package/dist/internal.js.map +1 -1
  36. package/dist/open-document.d.ts +15 -0
  37. package/dist/open-document.d.ts.map +1 -1
  38. package/dist/open-document.js +54 -1
  39. package/dist/open-document.js.map +1 -1
  40. package/dist/record-paging.d.ts +13 -14
  41. package/dist/record-paging.d.ts.map +1 -1
  42. package/dist/record-paging.js +13 -14
  43. package/dist/record-paging.js.map +1 -1
  44. package/dist/serializer.d.ts +3 -3
  45. package/dist/session-toolbar.d.ts.map +1 -1
  46. package/dist/session-toolbar.js +131 -9
  47. package/dist/session-toolbar.js.map +1 -1
  48. package/dist/version-bar.d.ts.map +1 -1
  49. package/dist/version-bar.js +23 -2
  50. package/dist/version-bar.js.map +1 -1
  51. package/dist/version.d.ts +2 -2
  52. package/dist/version.js +1 -1
  53. package/dist/when-saved.d.ts +2 -0
  54. package/dist/when-saved.d.ts.map +1 -0
  55. package/dist/when-saved.js +70 -0
  56. package/dist/when-saved.js.map +1 -0
  57. package/dist/ws-client.d.ts +4 -0
  58. package/dist/ws-client.d.ts.map +1 -1
  59. package/dist/ws-client.js +70 -1
  60. package/dist/ws-client.js.map +1 -1
  61. package/dist/ws-frame-limit.d.ts.map +1 -1
  62. package/dist/ws-frame-limit.js +9 -10
  63. package/dist/ws-frame-limit.js.map +1 -1
  64. package/docs/custom-chrome.md +4 -2
  65. package/package.json +1 -1
  66. package/src/client.ts +37 -2
  67. package/src/document-checkpoints.ts +30 -0
  68. package/src/document-signals.ts +113 -1
  69. package/src/document-thumbnail-capture.ts +253 -9
  70. package/src/documents-namespace.ts +20 -10
  71. package/src/documents.ts +120 -13
  72. package/src/handles.ts +6 -5
  73. package/src/importer.ts +11 -56
  74. package/src/internal.ts +6 -0
  75. package/src/open-document.ts +100 -1
  76. package/src/record-paging.ts +13 -14
  77. package/src/serializer.ts +3 -3
  78. package/src/session-toolbar.ts +139 -10
  79. package/src/version-bar.ts +24 -5
  80. package/src/version.ts +1 -1
  81. package/src/when-saved.ts +66 -0
  82. package/src/ws-client.ts +70 -1
  83. package/src/ws-frame-limit.ts +9 -10
package/src/documents.ts CHANGED
@@ -57,6 +57,7 @@ import {
57
57
  GoneSignal,
58
58
  WorkStatusSignal,
59
59
  DriveStatusSignal,
60
+ DrivePublishTargetSignal,
60
61
  type DocumentGoneListener,
61
62
  type DocumentGoneReason,
62
63
  type DocumentGoneUnsubscribe,
@@ -68,6 +69,8 @@ import {
68
69
  type DocumentDriveStatusUnsubscribe,
69
70
  type DocumentDriveTrackingState,
70
71
  type DriveMirrorDirection,
72
+ type DrivePublishTarget,
73
+ type DrivePublishState,
71
74
  } from './document-signals.js';
72
75
  import {
73
76
  ThumbnailCapture,
@@ -78,6 +81,7 @@ import {
78
81
  CheckpointScheduler,
79
82
  type CheckpointCreator,
80
83
  } from './document-checkpoints.js';
84
+ import { whenSaved } from './when-saved.js';
81
85
  import {
82
86
  BranchController,
83
87
  type BranchProvider,
@@ -120,6 +124,7 @@ export type {
120
124
  DocumentDriveStatusUnsubscribe,
121
125
  DocumentDriveTrackingState,
122
126
  DriveMirrorDirection,
127
+ DrivePublishTarget,
123
128
  };
124
129
 
125
130
  // The proxy callback receives an object with the root map (`entities`)
@@ -612,10 +617,25 @@ export class Document {
612
617
  this._fireSubscribers({ kind: 'branches' });
613
618
  },
614
619
  });
620
+ // The version of the newest picture the server holds of this document, or
621
+ // `null` for none. Seeded from the join and moved by this instance's own
622
+ // uploads; a picture older than the document's head is what arms the seed
623
+ // trigger, so a placeholder taken before the content arrived is replaced
624
+ // once it does.
625
+ private _thumbnailVersion: number | null = null;
615
626
  private readonly _thumbnails = new ThumbnailCapture({
627
+ // `_forcedReadOnly` and not the whole `readOnly`: the server refuses a
628
+ // thumbnail from a reader it will not take writes from, so a capture
629
+ // there costs a request and a warn to be told so. The load lock is the
630
+ // other half of `readOnly` and must NOT gate this, because a document
631
+ // still hydrating is exactly the one the seed watch is armed for, and
632
+ // the arm happens while that lock is still on.
616
633
  isCaptureAllowed: (): boolean =>
617
- this._isLive && this._checkout === null,
634
+ this._isLive && this._checkout === null && !this._forcedReadOnly,
618
635
  isAttached: (): boolean => this._isLive,
636
+ onCaptured: (): void => {
637
+ this._thumbnailVersion = this.currentSeq ?? 0;
638
+ },
619
639
  });
620
640
  private readonly _checkpoints = new CheckpointScheduler({
621
641
  docId: (): string | null => this._docId,
@@ -626,6 +646,7 @@ export class Document {
626
646
  sealUndoStep: (): void => {
627
647
  if (this._undo) this._undo.sealStep();
628
648
  },
649
+ whenLocalOpsSaved: (): Promise<boolean> => whenSaved(this),
629
650
  });
630
651
  // Drive push hook. `null` on the offline / test path; `pushToDrive`
631
652
  // then throws with a clear message rather than silently dropping.
@@ -738,6 +759,9 @@ export class Document {
738
759
  // Drive sync state, latest-retained. `null` until the open factory
739
760
  // seeds it (drive-linked docs only) and on docs with no drive link.
740
761
  private readonly _driveStatus = new DriveStatusSignal();
762
+ // Where this document could be published when it mirrors nothing yet.
763
+ // Seeded from the open, emptied once it becomes a mirror.
764
+ private readonly _drivePublishTarget = new DrivePublishTargetSignal();
741
765
 
742
766
  // Document-scoped presence. `null` until the WS transport is wired
743
767
  // on, and on the offline / test path where there is no transport.
@@ -1145,6 +1169,11 @@ export class Document {
1145
1169
  // reload/resync closure can't touch freed WASM. `_closed` only; a parked
1146
1170
  // doc still imports.
1147
1171
  if (this._closed) return;
1172
+ // Arm the seed BEFORE the import, because this is the moment the content
1173
+ // arrives and the Loro subscriber cannot be relied on to report it: it is
1174
+ // attached lazily, so a document whose content lands before the session has
1175
+ // bound its canvas has nobody subscribed and the import fires nothing.
1176
+ this._armSeedIfPictureIsStale();
1148
1177
  this._doc.import(snapshot);
1149
1178
  // The undo stack pre-snapshot referenced ops the new snapshot
1150
1179
  // supersedes. Drop both stacks so undo doesn't try to revert
@@ -1651,6 +1680,23 @@ export class Document {
1651
1680
  this._thumbnails.setUploader(uploader);
1652
1681
  }
1653
1682
 
1683
+ // Tell the document which version the server's picture of it depicts, from
1684
+ // the join, and start a seed watch when that picture is behind the content.
1685
+ /** @internal */
1686
+ _setThumbnailVersion(version: number | null): void {
1687
+ this._thumbnailVersion = version;
1688
+ this._armSeedIfPictureIsStale();
1689
+ }
1690
+
1691
+ // A picture at or above the head depicts what the document currently holds.
1692
+ private _armSeedIfPictureIsStale(): void {
1693
+ const head = this.currentSeq ?? 0;
1694
+ if (this._thumbnailVersion !== null && this._thumbnailVersion >= head) {
1695
+ return;
1696
+ }
1697
+ this._thumbnails.armSeed();
1698
+ }
1699
+
1654
1700
  // Install the checkpoint-create hook; `null` detaches.
1655
1701
  /** @internal */
1656
1702
  setCheckpointCreator(creator: CheckpointCreator | null): void {
@@ -1736,11 +1782,11 @@ export class Document {
1736
1782
  return await this._driveResolver();
1737
1783
  }
1738
1784
 
1739
- // Fetch the original file behind an opaque-blob document: a short-lived
1740
- // presigned GET plus the file's name / content type / size. An app that
1741
- // knows the format fetches the bytes from `url`, parses them, and writes
1742
- // the structured content into this document through the normal handle
1743
- // surface (`doc.map('instances').mutate(...)`):
1785
+ // Fetch the file a document was uploaded as: a short-lived presigned GET
1786
+ // plus the file's name / content type / size. An app that knows the format
1787
+ // fetches the bytes from `url`, parses them, and writes the structured
1788
+ // content into this document through the normal handle surface
1789
+ // (`doc.map('instances').mutate(...)`):
1744
1790
  //
1745
1791
  // const src = await doc.sourceBlob();
1746
1792
  // const bytes = new Uint8Array(await (await fetch(src.url)).arrayBuffer());
@@ -1752,9 +1798,8 @@ export class Document {
1752
1798
  // each with its own URL, so a multi-stream format (a glTF with its
1753
1799
  // `.bin` buffer) fetches only the streams it needs.
1754
1800
  //
1755
- // Rejects with a `BrassError` when the document is not a stored opaque
1756
- // blob (it has no original file to hand back) or the caller cannot
1757
- // reach it (both 404).
1801
+ // Rejects with a `BrassError` when the document holds no such file, or
1802
+ // the caller cannot reach it (both 404).
1758
1803
  async sourceBlob(): Promise<DocumentSourceBlob> {
1759
1804
  this._assertOpen();
1760
1805
  if (this._docId === null) {
@@ -2450,9 +2495,9 @@ export class Document {
2450
2495
  this._maybeFireUndoState();
2451
2496
  // A local commit (mutate / undo / redo) schedules a debounced
2452
2497
  // thumbnail capture, when the app has registered a canvas via
2453
- // `useCanvas`. Remote ops never trigger a capture: a viewer or
2454
- // a passive collaborator must not re-upload a screenshot of
2455
- // someone else's edit.
2498
+ // `useCanvas`. The debounce is for the peer that drew the change, so
2499
+ // it is local-only: a passive collaborator re-uploading a screenshot
2500
+ // of someone else's edit is what a per-op capture would be.
2456
2501
  if (isLocalCommit) {
2457
2502
  this._thumbnails.schedule();
2458
2503
  // Same reasoning for the implicit auto-checkpoint: only the
@@ -2460,6 +2505,17 @@ export class Document {
2460
2505
  // N-way session would write N redundant CHECKPOINT# rows per
2461
2506
  // idle period.
2462
2507
  this._checkpoints.scheduleAuto();
2508
+ } else {
2509
+ // Content arriving with no local commit behind it is what an import
2510
+ // looks like from here, and it moves the head past whatever picture
2511
+ // the document has. This DOES reach a reader who drew none of it,
2512
+ // which the debounce above deliberately does not: an import runs on
2513
+ // whichever client claimed it, so gating the seed on local authorship
2514
+ // would leave the documents that most need a picture without one.
2515
+ // Every such reader captures the same document at the same version,
2516
+ // and the targeted snapshot each upload enqueues is idempotent on
2517
+ // that version, so the concurrent seeds resolve themselves.
2518
+ this._armSeedIfPictureIsStale();
2463
2519
  }
2464
2520
  });
2465
2521
  }
@@ -2671,6 +2727,55 @@ export class Document {
2671
2727
  _emitDriveStatus(status: DocumentDriveStatus): void {
2672
2728
  if (this._closed) return;
2673
2729
  this._driveStatus.emit(status);
2730
+ // A document that has become a mirror publishes through its link from
2731
+ // now on, so the create-an-artifact offer goes with the arrival of the
2732
+ // status that replaces it.
2733
+ this._drivePublishTarget.set(null);
2734
+ }
2735
+
2736
+ // Whether this document's export runs in THIS app, which is what decides
2737
+ // whether a push carries bytes. Not on `driveStatus`: it is a fact about
2738
+ // the caller rather than about the document, and it is all an app is told
2739
+ // about the exporter, never which app it is when it is not this one.
2740
+ private _exportsHere = false;
2741
+
2742
+ /** @internal */
2743
+ get _exportRunsHere(): boolean {
2744
+ return this._exportsHere;
2745
+ }
2746
+
2747
+ /** @internal */
2748
+ _setExportRunsHere(runsHere: boolean): void {
2749
+ this._exportsHere = runsHere;
2750
+ }
2751
+
2752
+ // Internal: where this document can be published while it mirrors nothing.
2753
+ /** @internal */
2754
+ get _publishTarget(): DrivePublishTarget | null {
2755
+ return this._drivePublishTarget.target;
2756
+ }
2757
+
2758
+ /** @internal */
2759
+ _setPublishTarget(target: DrivePublishTarget | null): void {
2760
+ if (this._closed) return;
2761
+ this._drivePublishTarget.set(target);
2762
+ }
2763
+
2764
+ // Internal: how the publish of a document that mirrors nothing is going.
2765
+ /** @internal */
2766
+ get _publishState(): DrivePublishState {
2767
+ return this._drivePublishTarget.state;
2768
+ }
2769
+
2770
+ /** @internal */
2771
+ _emitPublishState(state: DrivePublishState): void {
2772
+ if (this._closed) return;
2773
+ this._drivePublishTarget.setState(state);
2774
+ }
2775
+
2776
+ /** @internal */
2777
+ _onPublishState(cb: () => void): () => void {
2778
+ return this._drivePublishTarget.onState(cb);
2674
2779
  }
2675
2780
 
2676
2781
  // Internal: mark the doc evicted. Idempotent (multiple frames arrive
@@ -3019,6 +3124,7 @@ export class Document {
3019
3124
  this._gone.clearListeners();
3020
3125
  this._workStatus.clearListeners();
3021
3126
  this._driveStatus.clearListeners();
3127
+ this._drivePublishTarget.clearListeners();
3022
3128
  // Presence subscribes its OWN WS topic and keeps it alive across a park
3023
3129
  // (resume does not re-create the controller), so drop its app-side
3024
3130
  // change listeners here the same way as the sets above: a parked doc
@@ -3086,7 +3192,7 @@ export class Document {
3086
3192
  this._undo.free();
3087
3193
  this._undo = null;
3088
3194
  }
3089
- this._thumbnails.cancel();
3195
+ this._thumbnails.destroy();
3090
3196
  this._checkpoints.cancelAuto();
3091
3197
  this._localOpHook = null;
3092
3198
  this._currentSeqProvider = null;
@@ -3102,6 +3208,7 @@ export class Document {
3102
3208
  this._gone.clearListeners();
3103
3209
  this._workStatus.clearListeners();
3104
3210
  this._driveStatus.clearListeners();
3211
+ this._drivePublishTarget.clearListeners();
3105
3212
  // Detach handle subscriptions before freeing the doc (below): a handle's
3106
3213
  // per-container Loro subscription outlives the cache reference, and
3107
3214
  // `_doc.free()` must not run with subscriptions still attached.
package/src/handles.ts CHANGED
@@ -816,9 +816,10 @@ function wrapLoroListProxy(
816
816
  // reading / editing `doc.map('instances')` works unchanged. Only a root
817
817
  // the `meta` markers name reaches here.
818
818
 
819
- // A flattened entry: whatever the map stores under one key. A codec's record
820
- // store holds `{ type, params }`; an app's map holds whatever the app assigns,
821
- // which is any JSON value and which the paged path carries through unchanged.
819
+ // A flattened entry: whatever the map stores under one key. A store of typed
820
+ // records holds `{ type, params }`; an app's map holds whatever the app
821
+ // assigns, which is any JSON value and which the paged path carries through
822
+ // unchanged.
822
823
  // Deliberately not narrowed to an object: a write path that assumes fields is
823
824
  // what turns `5` into an empty container and `'hello'` into one entry per
824
825
  // character.
@@ -2034,8 +2035,8 @@ function rewritePage(
2034
2035
  const page = plainEntry(m, pageKey);
2035
2036
  if (page === undefined) return false;
2036
2037
  // The RAW triples, not the memo's unpacked records: a triple carries either
2037
- // a codec's tag beside its body or one whole value, and `repackRecord` needs
2038
- // the original to keep each one in the shape its readers expect.
2038
+ // a tag beside a body or one whole value, and `repackRecord` needs the
2039
+ // original to keep each one in the shape its readers expect.
2039
2040
  const held = decodePageRecords(page);
2040
2041
  if (held.length === 0) return false;
2041
2042
  const next = new Map(group);
package/src/importer.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Document } from './documents.js';
2
2
  import type { DocumentSourceBlob } from './documents-namespace.js';
3
3
  import { BrassError } from './errors.js';
4
+ import { whenSaved } from './when-saved.js';
4
5
 
5
6
  // The app's import half for a file Brass does not parse: given the stored
6
7
  // source file and the open document, parse the bytes and write the records
@@ -174,6 +175,7 @@ export function createImportRunner(deps: ImportRunnerDeps): ImportRunner {
174
175
  const stopWatchingWrites = doc.subscribe(() => {
175
176
  if (doc.savingCount > 0) wroteRecords = true;
176
177
  });
178
+ let saved = false;
177
179
  try {
178
180
  await run(source, doc);
179
181
  // The importer's ops are still in flight when it returns, and
@@ -181,19 +183,19 @@ export function createImportRunner(deps: ImportRunnerDeps): ImportRunner {
181
183
  // the head it lands on. Completing before they are acked stamps
182
184
  // a version below the records' own, which reads as a document
183
185
  // edited since it was uploaded from then on.
184
- await whenSaved(doc);
186
+ saved = await whenSaved(doc);
185
187
  } finally {
186
188
  stopRefresh();
187
189
  stopWatchingWrites();
188
190
  }
189
- // The wait above ends on the document going as well as on its writes
190
- // draining, and only one of those means the records are stored. The
191
- // completion stamps the document with the app's schema at the version
192
- // it lands on, so running it over writes that never reached the server
193
- // records an import of records the document does not hold. Abandoning
194
- // instead leaves the claim to expire, and a client that is still open
195
- // imports the file properly.
196
- if (!doc._isLive) return;
191
+ // The wait ends on the document going, and on the acks stopping, as
192
+ // well as on the writes draining, and only the last of those means
193
+ // the records are stored. The completion stamps the document with the
194
+ // app's schema at the version it lands on, so running it over writes
195
+ // that never reached the server records an import of records the
196
+ // document does not hold. Abandoning instead leaves the claim to
197
+ // expire, and a client that is still open imports the file properly.
198
+ if (!saved) return;
197
199
  // An importer that returns without writing anything must not stamp the
198
200
  // document imported: the stamp is permanent, a later open reads
199
201
  // `already_imported`, and the file's records then have no path into a
@@ -270,53 +272,6 @@ export function createImportRunner(deps: ImportRunnerDeps): ImportRunner {
270
272
  return runner;
271
273
  }
272
274
 
273
- // How often the wait below re-checks that the document is still live. A
274
- // closing document drops its subscribers without firing them, so this is the
275
- // only thing that ends the wait for one that goes mid-import; it runs solely
276
- // while an import's writes are draining.
277
- const SAVED_LIVENESS_POLL_MS = 1_000;
278
-
279
- // Resolves once the document has no locally-issued op waiting for its
280
- // server ack, or once the document is no longer live.
281
- //
282
- // The liveness half is not belt-and-braces. Closing a Document CLEARS its
283
- // subscribers rather than firing them, so a wait that watched the ack count
284
- // alone would never settle for a document closed with writes outstanding, and
285
- // the caller's `finally` (which stops renewing the platform's import claim)
286
- // would never run. Resolving here does not claim the writes landed: the
287
- // completion that follows is what asserts that, and it fails against a
288
- // document this client can no longer write.
289
- function whenSaved(doc: Document): Promise<void> {
290
- if (doc.savingCount === 0) return Promise.resolve();
291
- return new Promise<void>((resolve) => {
292
- let off: (() => void) | undefined;
293
- let poll: ReturnType<typeof setInterval> | undefined;
294
- const settle = (): void => {
295
- off?.();
296
- if (poll !== undefined) clearInterval(poll);
297
- resolve();
298
- };
299
- try {
300
- off = doc.subscribe(() => {
301
- if (doc.savingCount > 0) return;
302
- settle();
303
- });
304
- } catch {
305
- // The document closed, so nothing more is going to be acked.
306
- resolve();
307
- return;
308
- }
309
- poll = setInterval(() => {
310
- if (doc._isLive && doc.savingCount > 0) return;
311
- settle();
312
- }, SAVED_LIVENESS_POLL_MS);
313
- // Node keeps the process alive for a pending interval; this one is
314
- // bookkeeping for a wait that ends on its own, so it must not.
315
- (poll as { unref?: () => void }).unref?.();
316
- if (doc.savingCount === 0) settle();
317
- });
318
- }
319
-
320
275
  function asError(err: unknown): Error {
321
276
  return err instanceof Error ? err : new Error(String(err));
322
277
  }
package/src/internal.ts CHANGED
@@ -21,4 +21,10 @@ export {
21
21
  type TokenSet,
22
22
  } from './auth-flow.js';
23
23
  export { decodeIdToken } from './jwt.js';
24
+ export {
25
+ fetchWithRetry,
26
+ RETRY_READ,
27
+ type RetryOptions,
28
+ type RetryPolicy,
29
+ } from './fetch-retry.js';
24
30
  export { BRASS_INIT_ARG_KEYS, type BrassInitArgKey } from './init-keys.js';
@@ -11,6 +11,7 @@ import {
11
11
  type DocumentOptions,
12
12
  type DocumentWorkStatus,
13
13
  type DocumentDriveStatus,
14
+ type DrivePublishTarget,
14
15
  type DocumentDriveTrackingState,
15
16
  type DriveMirrorDirection,
16
17
  type HostedUiProvider,
@@ -214,6 +215,33 @@ export interface DocumentSnapshotResponse {
214
215
  // Drive provenance + tracking state, present only on drive-linked docs.
215
216
  // Seeds `doc.driveStatus`; live changes stream in via `doc_drive_status`.
216
217
  drive?: DocumentDriveSummaryWire;
218
+ // Whether this document's export runs in the app this client acts as.
219
+ // Server-answered per caller, so it says only that this app is the one to
220
+ // serialize, never which app is if it is not. Absent for a caller carrying
221
+ // no app assertion.
222
+ calling_app_exports?: boolean;
223
+ // The version of the document's current picture, absent when it has none.
224
+ // Only the version is read (a picture behind the head is what makes this
225
+ // open seed one), so the shape is deliberately narrow: the full wire record
226
+ // is the thumbnail routes' surface, not the join's.
227
+ thumbnail?: { version?: number } | null;
228
+ // Where a document that mirrors nothing can be published, present only
229
+ // while it mirrors nothing and its project is linked to a drive that
230
+ // takes a new file. The hosted drive badge offers the publish off this.
231
+ drive_publish_target?: DrivePublishTargetWire;
232
+ }
233
+
234
+ // Wire shape of a publish target on the join response. Snake_case,
235
+ // mirroring the server's `DocumentDrivePublishTarget` (CONTRACT.md "Where
236
+ // a document with no drive item can publish").
237
+ export interface DrivePublishTargetWire {
238
+ drive_type?: unknown;
239
+ drive_name?: unknown;
240
+ container_name?: unknown;
241
+ detail?: unknown;
242
+ source_unreadable?: unknown;
243
+ publishing?: unknown;
244
+ publish_failed?: unknown;
217
245
  }
218
246
 
219
247
  // Wire shape of the `drive` block carried on the join response and the
@@ -237,6 +265,7 @@ export interface DocumentDriveSummaryWire {
237
265
  // Optional: a frame from an older server omits it, treated as not
238
266
  // publishing (the `canPush` affordance then behaves as before).
239
267
  publishing?: boolean;
268
+ source_unreadable?: string;
240
269
  }
241
270
 
242
271
  // Wire-protocol-pinned response shape from
@@ -620,6 +649,21 @@ interface DocDriveStatusFrame {
620
649
  drive: DocumentDriveSummaryWire;
621
650
  }
622
651
 
652
+ // Server-pushed publish state for a document that mirrors nothing on a drive
653
+ // yet. Wire shape pinned in CONTRACT.md "Server-pushed `doc_drive_publish`".
654
+ // The first publish creates the artifact, so until it lands the document has
655
+ // no drive summary and `doc_drive_status` carries nothing about the push it
656
+ // started.
657
+ interface DocDrivePublishFrame {
658
+ type: 'doc_drive_publish';
659
+ topic: string;
660
+ publish: {
661
+ publishing?: unknown;
662
+ failed?: unknown;
663
+ detail?: unknown;
664
+ };
665
+ }
666
+
623
667
  // Server-pushed frame fired when the platform discards ops it cannot apply.
624
668
  // The prompt half of the durability
625
669
  // handshake: the SDK re-checks durability against ground truth now rather
@@ -651,7 +695,9 @@ function toDriveStatus(wire: DocumentDriveSummaryWire): DocumentDriveStatus {
651
695
  // update) must not be treated as unbroken and offered a push the server
652
696
  // would reject. Require an explicit `false` to enable, and an explicit
653
697
  // `true` to flag broken. Behaviour is unchanged for today's frames, which
654
- // always carry the boolean.
698
+ // always carry the boolean. Whether the file is written server-side or
699
+ // from the app's own serializer is a REQUEST SHAPE the push path resolves
700
+ // for itself, so it says nothing about whether this document can publish.
655
701
  canPush:
656
702
  wire.broken === false &&
657
703
  mirrorDirection === 'two_way' &&
@@ -666,6 +712,31 @@ function toDriveStatus(wire: DocumentDriveSummaryWire): DocumentDriveStatus {
666
712
  itemName: wire.item_name,
667
713
  itemUrl: wire.item_url,
668
714
  publishing: wire.publishing === true,
715
+ // Spread rather than assigned, because `exactOptionalPropertyTypes`
716
+ // separates an absent optional field from one holding `undefined`.
717
+ ...(wire.source_unreadable !== undefined
718
+ ? { sourceUnreadable: wire.source_unreadable }
719
+ : {}),
720
+ };
721
+ }
722
+
723
+ // Map the wire target to the public shape, dropping one that does not carry
724
+ // what a publish needs. A malformed target is skipped rather than failing
725
+ // the open: the document is readable whatever its publish offer says.
726
+ function toPublishTarget(
727
+ wire: DrivePublishTargetWire | undefined,
728
+ ): DrivePublishTarget | null {
729
+ if (typeof wire !== 'object' || wire === null) return null;
730
+ const driveName = wire.drive_name;
731
+ if (typeof driveName !== 'string' || driveName === '') return null;
732
+ return {
733
+ driveName,
734
+ driveType: typeof wire.drive_type === 'string' ? wire.drive_type : '',
735
+ containerName:
736
+ typeof wire.container_name === 'string' ? wire.container_name : '',
737
+ detail: typeof wire.detail === 'string' ? wire.detail : '',
738
+ publishing: wire.publishing === true,
739
+ publishFailed: wire.publish_failed === true,
669
740
  };
670
741
  }
671
742
 
@@ -969,6 +1040,15 @@ function isDocMetaFrame(frame: unknown): frame is DocMetaFrame {
969
1040
  );
970
1041
  }
971
1042
 
1043
+ function isDocDrivePublishFrame(frame: unknown): frame is DocDrivePublishFrame {
1044
+ if (typeof frame !== 'object' || frame === null) return false;
1045
+ const f = frame as Record<string, unknown>;
1046
+ if (f.type !== 'doc_drive_publish' || typeof f.topic !== 'string') {
1047
+ return false;
1048
+ }
1049
+ return typeof f.publish === 'object' && f.publish !== null;
1050
+ }
1051
+
972
1052
  function isDocDriveStatusFrame(frame: unknown): frame is DocDriveStatusFrame {
973
1053
  if (typeof frame !== 'object' || frame === null) return false;
974
1054
  const f = frame as Record<string, unknown>;
@@ -1738,10 +1818,19 @@ export function createOpenDocument(
1738
1818
  // (a stub opened mid-hydration returns `snapshot: null` + a `pending`
1739
1819
  // status).
1740
1820
  if (parsed.status !== undefined) doc._emitStatus(parsed.status);
1821
+ // Whether this app is the one to serialize the document, which is what
1822
+ // decides whether a push carries bytes. Answered by the server for the
1823
+ // caller's own app and only on the join, since the fanout frame is one
1824
+ // payload for every subscriber and has no caller to answer for.
1825
+ doc._setExportRunsHere(parsed.calling_app_exports === true);
1741
1826
  // Seed `doc.driveStatus` from the join before the first
1742
1827
  // `doc_drive_status` frame.
1743
1828
  if (parsed.drive !== undefined) {
1744
1829
  doc._emitDriveStatus(toDriveStatus(parsed.drive));
1830
+ } else {
1831
+ // Only for a document that mirrors nothing: a mirror publishes through
1832
+ // its link, and `_emitDriveStatus` clears any targets anyway.
1833
+ doc._setPublishTarget(toPublishTarget(parsed.drive_publish_target));
1745
1834
  }
1746
1835
  // Highest seq seen; later `doc_op` / `op_ack` frames advance it. Kept in
1747
1836
  // a closure, not on the Document: it is a transport concern, not CRDT
@@ -2161,6 +2250,7 @@ export function createOpenDocument(
2161
2250
  await uploader(doc, image, width, height);
2162
2251
  });
2163
2252
  }
2253
+ doc._setThumbnailVersion(parsed.thumbnail?.version ?? null);
2164
2254
  if (opts.checkpointCreator) {
2165
2255
  const creator = opts.checkpointCreator;
2166
2256
  doc.setCheckpointCreator(() => creator(doc));
@@ -4300,6 +4390,15 @@ function wireDocTransport(args: WireDocTransportArgs): void {
4300
4390
  args.doc._emitDriveStatus(toDriveStatus(frame.drive));
4301
4391
  return;
4302
4392
  }
4393
+ if (isDocDrivePublishFrame(frame)) {
4394
+ args.doc._emitPublishState({
4395
+ publishing: frame.publish.publishing === true,
4396
+ failed: frame.publish.failed === true,
4397
+ detail:
4398
+ typeof frame.publish.detail === 'string' ? frame.publish.detail : null,
4399
+ });
4400
+ return;
4401
+ }
4303
4402
  if (isDocOpsDroppedFrame(frame)) {
4304
4403
  // Prompt durability re-check: the discard sealed a new snapshot, so
4305
4404
  // re-fetch the moved frontier and run the handshake now instead of at
@@ -232,16 +232,15 @@ const TOMBSTONE_PREFIX = `${RESERVED_KEY_PREFIX}x:`;
232
232
 
233
233
  /** Pack one stored value into a page triple. A `null` tag is what says the
234
234
  * body IS the record: an app's map holds whatever the app assigned, which has
235
- * no tag to split off. A codec's records arrive already split, and a codec
236
- * tag is a string, so the two forms cannot be confused however a codec names
237
- * its records. */
235
+ * no tag to split off. A typed record arrives already split, and its tag is a
236
+ * string, so the two forms cannot be confused whatever the tag says. */
238
237
  export function packWholeValue<B>(key: string, value: B): PackedRecord<B> {
239
238
  return [key, null, value];
240
239
  }
241
240
 
242
241
  /** Re-pack `value` in the same triple shape `packed` already uses, so a page
243
- * rewritten in place keeps the form its readers expect: a codec's records
244
- * stay a tag beside a body, and an app's stay one whole value. A streaming
242
+ * rewritten in place keeps the form its readers expect: a typed record stays
243
+ * a tag beside a body, and an app's stays one whole value. A streaming
245
244
  * read visits a page's triples without unpacking them, so a rewrite that
246
245
  * changed the shape would hand it the wrong half of the record. */
247
246
  export function repackRecord(
@@ -259,7 +258,7 @@ export function repackRecord(
259
258
  }
260
259
 
261
260
  /** The stored value a page triple carries: the value itself for a whole-value
262
- * record, else the `{ type, params }` pair a codec split it into. */
261
+ * record, else the `{ type, params }` pair a typed one was split into. */
263
262
  export function unpackRecord(packed: PackedRecord<unknown>): unknown {
264
263
  if (packed[1] === null) return packed[2];
265
264
  return { [TAG_FIELD]: packed[1], [BODY_FIELD]: packed[2] };
@@ -269,12 +268,12 @@ export function unpackRecord(packed: PackedRecord<unknown>): unknown {
269
268
  * separately, or null when the triple packs a whole value that is not a
270
269
  * record.
271
270
  *
272
- * A page holds both forms: a codec packs a record it has already split into a
273
- * tag and a body, and a map an app writes packs whatever the app assigned as
274
- * one whole value. Both forms occur in ONE store, because an app's bulk add
275
- * to a codec's record map seals its own pages, so a reader that took
276
- * `packed[1]` for the tag would report every record those pages hold as
277
- * having no type at all. */
271
+ * A page holds both forms: a typed record is packed as the tag and body it
272
+ * was already split into, and a map an app writes packs whatever the app
273
+ * assigned as one whole value. Both forms occur in ONE store, because an
274
+ * app's bulk add to a store of typed records seals its own pages, so a reader
275
+ * that took `packed[1]` for the tag would report every record those pages
276
+ * hold as having no type at all. */
278
277
  export function unpackRecordPair(
279
278
  packed: PackedRecord<unknown>,
280
279
  ): { tag: string; body: unknown } | null {
@@ -454,7 +453,7 @@ const WIDTH_SAMPLE = 256;
454
453
  /** Mean JSON bytes of a record body, over a stride sample of at most
455
454
  * `WIDTH_SAMPLE` records, which is what `recordsPerPage` sizes a page
456
455
  * against. `bodyOf` returns the value the caller will pack as the triple's
457
- * body: a codec's records carry a tag beside a body, and an app's map packs
456
+ * body: a typed record carries a tag beside a body, and an app's map packs
458
457
  * its whole stored value (`packWholeValue`), so each caller weighs the bytes
459
458
  * its own pages will hold.
460
459
  *
@@ -673,7 +672,7 @@ export function applyRecordPatches(
673
672
  * value and as the `(tag, body)` pair split out of it, and return all three
674
673
  * after the fold.
675
674
  *
676
- * A patch names the record's own fields, which for a codec record are
675
+ * A patch names the record's own fields, which for a typed record are
677
676
  * `type` and `params` together, so a reader that walks tag and body
678
677
  * separately has to re-split the result. Both readers of a paged store
679
678
  * (the flattening one and the streaming one) resolve patches through this
package/src/serializer.ts CHANGED
@@ -22,9 +22,9 @@ export interface StoredList<T = unknown> extends ListHandle<T> {
22
22
  /**
23
23
  * The document as a serializer sees it: the same containers, readable whole.
24
24
  *
25
- * A `Document` satisfies this, which is what keeps a built-in codec and a
26
- * third-party one on one surface. The difference between them is where they
27
- * run, not what they are allowed to reach.
25
+ * A `Document` satisfies this, which is what keeps a serializer the platform
26
+ * ships and one an app registers on one surface. The difference between them
27
+ * is where they run, not what they are allowed to reach.
28
28
  */
29
29
  export interface StoredDocument {
30
30
  readonly id: string | null;