@jolly-pixel/asset-server 1.0.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 (139) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +83 -0
  3. package/dist/catalog/CatalogExtension.d.ts +37 -0
  4. package/dist/catalog/CatalogExtension.d.ts.map +1 -0
  5. package/dist/catalog/CatalogExtension.js +82 -0
  6. package/dist/catalog/CatalogIdentitySidecar.d.ts +52 -0
  7. package/dist/catalog/CatalogIdentitySidecar.d.ts.map +1 -0
  8. package/dist/catalog/CatalogIdentitySidecar.js +132 -0
  9. package/dist/catalog/CatalogProjection.d.ts +32 -0
  10. package/dist/catalog/CatalogProjection.d.ts.map +1 -0
  11. package/dist/catalog/CatalogProjection.js +109 -0
  12. package/dist/catalog/httpHandler.d.ts +13 -0
  13. package/dist/catalog/httpHandler.d.ts.map +1 -0
  14. package/dist/catalog/httpHandler.js +27 -0
  15. package/dist/catalog/index.d.ts +9 -0
  16. package/dist/catalog/index.d.ts.map +1 -0
  17. package/dist/catalog/index.js +4 -0
  18. package/dist/constants.d.ts +9 -0
  19. package/dist/constants.d.ts.map +1 -0
  20. package/dist/constants.js +13 -0
  21. package/dist/createAssetBackend.d.ts +79 -0
  22. package/dist/createAssetBackend.d.ts.map +1 -0
  23. package/dist/createAssetBackend.js +146 -0
  24. package/dist/events/AssetEvents.d.ts +54 -0
  25. package/dist/events/AssetEvents.d.ts.map +1 -0
  26. package/dist/events/AssetEvents.js +102 -0
  27. package/dist/events/AssetEvents.schema.d.ts +111 -0
  28. package/dist/events/AssetEvents.schema.d.ts.map +1 -0
  29. package/dist/events/AssetEvents.schema.js +75 -0
  30. package/dist/events/index.d.ts +3 -0
  31. package/dist/events/index.d.ts.map +1 -0
  32. package/dist/events/index.js +1 -0
  33. package/dist/index.d.ts +13 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +11 -0
  36. package/dist/kinds/AssetKindHandler.d.ts +38 -0
  37. package/dist/kinds/AssetKindHandler.d.ts.map +1 -0
  38. package/dist/kinds/AssetKindHandler.js +1 -0
  39. package/dist/kinds/AssetKindRegistry.d.ts +14 -0
  40. package/dist/kinds/AssetKindRegistry.d.ts.map +1 -0
  41. package/dist/kinds/AssetKindRegistry.js +52 -0
  42. package/dist/kinds/errors/UnknownAssetKindError.d.ts +5 -0
  43. package/dist/kinds/errors/UnknownAssetKindError.d.ts.map +1 -0
  44. package/dist/kinds/errors/UnknownAssetKindError.js +8 -0
  45. package/dist/kinds/errors/index.d.ts +2 -0
  46. package/dist/kinds/errors/index.d.ts.map +1 -0
  47. package/dist/kinds/errors/index.js +1 -0
  48. package/dist/kinds/handlers/binary.d.ts +10 -0
  49. package/dist/kinds/handlers/binary.d.ts.map +1 -0
  50. package/dist/kinds/handlers/binary.js +31 -0
  51. package/dist/kinds/handlers/index.d.ts +5 -0
  52. package/dist/kinds/handlers/index.d.ts.map +1 -0
  53. package/dist/kinds/handlers/index.js +2 -0
  54. package/dist/kinds/handlers/texture.d.ts +12 -0
  55. package/dist/kinds/handlers/texture.d.ts.map +1 -0
  56. package/dist/kinds/handlers/texture.js +27 -0
  57. package/dist/kinds/index.d.ts +5 -0
  58. package/dist/kinds/index.d.ts.map +1 -0
  59. package/dist/kinds/index.js +3 -0
  60. package/dist/logger.d.ts +4 -0
  61. package/dist/logger.d.ts.map +1 -0
  62. package/dist/logger.js +9 -0
  63. package/dist/plugins/vite.d.ts +36 -0
  64. package/dist/plugins/vite.d.ts.map +1 -0
  65. package/dist/plugins/vite.js +60 -0
  66. package/dist/rooms/AssetRoomExtension.d.ts +29 -0
  67. package/dist/rooms/AssetRoomExtension.d.ts.map +1 -0
  68. package/dist/rooms/AssetRoomExtension.js +50 -0
  69. package/dist/rooms/index.d.ts +5 -0
  70. package/dist/rooms/index.d.ts.map +1 -0
  71. package/dist/rooms/index.js +2 -0
  72. package/dist/rooms/registerAssetRooms.d.ts +21 -0
  73. package/dist/rooms/registerAssetRooms.d.ts.map +1 -0
  74. package/dist/rooms/registerAssetRooms.js +71 -0
  75. package/dist/static/contentTypes.d.ts +6 -0
  76. package/dist/static/contentTypes.d.ts.map +1 -0
  77. package/dist/static/contentTypes.js +27 -0
  78. package/dist/static/httpHandler.d.ts +24 -0
  79. package/dist/static/httpHandler.d.ts.map +1 -0
  80. package/dist/static/httpHandler.js +105 -0
  81. package/dist/static/index.d.ts +4 -0
  82. package/dist/static/index.d.ts.map +1 -0
  83. package/dist/static/index.js +2 -0
  84. package/dist/sync/AssetProjector.d.ts +33 -0
  85. package/dist/sync/AssetProjector.d.ts.map +1 -0
  86. package/dist/sync/AssetProjector.js +186 -0
  87. package/dist/sync/AssetStateStore.d.ts +28 -0
  88. package/dist/sync/AssetStateStore.d.ts.map +1 -0
  89. package/dist/sync/AssetStateStore.js +86 -0
  90. package/dist/sync/AssetWriter.d.ts +54 -0
  91. package/dist/sync/AssetWriter.d.ts.map +1 -0
  92. package/dist/sync/AssetWriter.js +149 -0
  93. package/dist/sync/ProjectionState.d.ts +33 -0
  94. package/dist/sync/ProjectionState.d.ts.map +1 -0
  95. package/dist/sync/ProjectionState.js +129 -0
  96. package/dist/sync/Reconciler.d.ts +36 -0
  97. package/dist/sync/Reconciler.d.ts.map +1 -0
  98. package/dist/sync/Reconciler.js +182 -0
  99. package/dist/sync/ReconciliationWatcher.d.ts +28 -0
  100. package/dist/sync/ReconciliationWatcher.d.ts.map +1 -0
  101. package/dist/sync/ReconciliationWatcher.js +86 -0
  102. package/dist/sync/SnapshotScheduler.d.ts +29 -0
  103. package/dist/sync/SnapshotScheduler.d.ts.map +1 -0
  104. package/dist/sync/SnapshotScheduler.js +136 -0
  105. package/dist/sync/foldProjection.d.ts +12 -0
  106. package/dist/sync/foldProjection.d.ts.map +1 -0
  107. package/dist/sync/foldProjection.js +32 -0
  108. package/dist/sync/index.d.ts +19 -0
  109. package/dist/sync/index.d.ts.map +1 -0
  110. package/dist/sync/index.js +9 -0
  111. package/dist/sync/matchRenames.d.ts +45 -0
  112. package/dist/sync/matchRenames.d.ts.map +1 -0
  113. package/dist/sync/matchRenames.js +85 -0
  114. package/dist/utils/TaskChain.d.ts +9 -0
  115. package/dist/utils/TaskChain.d.ts.map +1 -0
  116. package/dist/utils/TaskChain.js +14 -0
  117. package/dist/utils/contentHash.d.ts +2 -0
  118. package/dist/utils/contentHash.d.ts.map +1 -0
  119. package/dist/utils/contentHash.js +7 -0
  120. package/dist/utils/index.d.ts +3 -0
  121. package/dist/utils/index.d.ts.map +1 -0
  122. package/dist/utils/index.js +2 -0
  123. package/dist/workspace/createAssetWorkspace.d.ts +71 -0
  124. package/dist/workspace/createAssetWorkspace.d.ts.map +1 -0
  125. package/dist/workspace/createAssetWorkspace.js +72 -0
  126. package/dist/workspace/index.d.ts +5 -0
  127. package/dist/workspace/index.d.ts.map +1 -0
  128. package/dist/workspace/index.js +2 -0
  129. package/dist/workspace/seedAssetSource.d.ts +5 -0
  130. package/dist/workspace/seedAssetSource.d.ts.map +1 -0
  131. package/dist/workspace/seedAssetSource.js +20 -0
  132. package/docs/AssetBackend.md +73 -0
  133. package/docs/AssetKinds.md +204 -0
  134. package/docs/AssetWriter.md +62 -0
  135. package/docs/Catalog.md +71 -0
  136. package/docs/Rooms.md +70 -0
  137. package/docs/Sync.md +146 -0
  138. package/docs/Workspace.md +172 -0
  139. package/package.json +83 -0
@@ -0,0 +1,204 @@
1
+ # Asset kinds
2
+
3
+ An `AssetKindHandler` defines how one asset type is recognized, folded into
4
+ state and serialized.
5
+
6
+ ```ts
7
+ interface AssetKindHandler<TState = unknown, TCommand = unknown> {
8
+ readonly kind: string;
9
+ readonly match: readonly string[];
10
+ readonly snapshot?: SnapshotPolicy;
11
+
12
+ create(assetId: string): TState;
13
+ apply(state: TState, event: Event): void;
14
+ serialize(state: TState): Promise<Uint8Array>;
15
+ live?(binding: AssetRoomBinding<TState>): AssetLiveProtocol<TCommand>;
16
+ createExtension?(binding: AssetRoomBinding<TState>): Extension;
17
+ }
18
+ ```
19
+
20
+ Handlers are checked in registration order. `match` contains globs matched
21
+ against root-relative POSIX paths. The built-in `binary` handler receives any
22
+ path that no registered handler claims.
23
+
24
+ `apply` receives lifecycle events and domain events from the asset stream.
25
+ `serialize` returns the bytes stored by the asset source. A handler that
26
+ supports live editing provides `live`; other kinds have no dynamic editing
27
+ room.
28
+
29
+ `apply` must reset the existing `TState` in place for `asset.created`,
30
+ `asset.updated` and `asset.deleted`. Each event is a complete checkpoint.
31
+ Replay creates a fresh state, resumes at the newest checkpoint and folds later
32
+ events. Reassigning the `state` parameter has no effect because `apply` returns
33
+ `void` and the store retains the value returned by `create`.
34
+
35
+ `TState` defaults to `unknown`, so a handler declared without it must narrow
36
+ its own state before use. Pass the state type to keep `create`, `apply` and
37
+ `serialize` checked against each other.
38
+
39
+ ## Reading lifecycle payloads
40
+
41
+ `event.eventData` is typed `unknown` by the event store, because the store
42
+ holds any domain. Parse it with `parseAssetEvent` rather than asserting a
43
+ shape: it validates the payload against a JSON Schema for its event type and
44
+ returns a `Result` carrying the parsed event, or the reason it was refused.
45
+
46
+ ```ts
47
+ apply(state: MyState, event: Event): void {
48
+ const parsed = parseAssetEvent(event);
49
+ if (parsed.ok && parsed.val.eventType === ASSET_UPDATED) {
50
+ // eventData is AssetWriteData here
51
+ state.bytes = decodeContent(parsed.val.eventData.content);
52
+ }
53
+ }
54
+ ```
55
+
56
+ Payloads read straight from persistence are parsed JSON, so a corrupt row
57
+ would otherwise reach the fold unchecked.
58
+
59
+ ## Snapshot policy
60
+
61
+ ```ts
62
+ interface SnapshotPolicy {
63
+ delay?: number;
64
+ maxDelay?: number;
65
+ }
66
+ ```
67
+
68
+ `delay` is the quiet period after the latest domain event. `maxDelay` limits
69
+ the time since the first unsnapshotted event. Backend defaults are `2_000` ms
70
+ and `30_000` ms. A handler can override either value through `snapshot`.
71
+
72
+ `delay: 0` schedules the snapshot for the next timer turn. Lifecycle events
73
+ do not schedule snapshots.
74
+
75
+ ## Registry
76
+
77
+ ```ts
78
+ const kinds = new AssetKindRegistry([pixelArtHandler]);
79
+
80
+ kinds.register(voxelHandler);
81
+ kinds.resolve("textures/grass.png");
82
+ kinds.get("pixelart");
83
+ ```
84
+
85
+ Registering the same kind twice throws. The reserved `binary` fallback cannot
86
+ be replaced.
87
+
88
+ ## Built-in kinds
89
+
90
+ `binary` is the reserved fallback. `texture` is a shipped handler that claims
91
+ image files so a runtime `AssetType` of the same name can resolve them:
92
+
93
+ ```ts
94
+ import { textureAssetHandler } from "@jolly-pixel/asset-server";
95
+
96
+ const kinds = new AssetKindRegistry([textureAssetHandler()]);
97
+ ```
98
+
99
+ Its state is the file's bytes, exactly like `binary`, and it has no `live`
100
+ protocol, so texture assets get no editing room. The kind exists to
101
+ name the record: `AssetCatalog.resolve()` rejects a record whose kind does not
102
+ match its reference, and nothing on the browser side loads `binary`. Pass
103
+ `match` to narrow the globs from the default image extensions.
104
+
105
+ ## Kinds shipped by other packages
106
+
107
+ Two handlers live with the domain they serialize rather than here, because
108
+ asset-server does not depend on the renderers:
109
+
110
+ ```ts
111
+ import { pixelArtAssetHandler } from "@jolly-pixel/pixel-draw.renderer/asset/index.ts";
112
+ import { voxelMapAssetHandler } from "@jolly-pixel/voxel.renderer/asset/index.ts";
113
+
114
+ await createAssetBackend({
115
+ source,
116
+ eventStore,
117
+ handlers: [
118
+ pixelArtAssetHandler(),
119
+ voxelMapAssetHandler(),
120
+ textureAssetHandler()
121
+ ]
122
+ });
123
+ ```
124
+
125
+ Both take `@jolly-pixel/asset-server` as an optional peer dependency, so a
126
+ browser-only consumer of either renderer never installs it.
127
+
128
+ ## Writing an editable kind
129
+
130
+ A kind with live editing has two halves that must not overlap: `apply` is the
131
+ only writer of state, and `live` describes a room that appends without
132
+ writing. `AssetRoomExtension` hosts the protocol, so a kind supplies only
133
+ what is specific to it:
134
+
135
+ ```ts
136
+ interface AssetLiveProtocol<TCommand = unknown> {
137
+ readonly commandEventType: string;
138
+ readonly actions: readonly string[];
139
+
140
+ parse(payload: unknown): TCommand | null;
141
+ snapshot(): unknown;
142
+ arbitrate(
143
+ command: TCommand,
144
+ clientId: string
145
+ ): AssetArbitration<TCommand> | null;
146
+ broadcast?(command: TCommand): AssetRoomMessage;
147
+ }
148
+ ```
149
+
150
+ `live` runs once per room, so per-room state such as a conflict tracker
151
+ belongs in the returned protocol rather than in the handler:
152
+
153
+ ```ts
154
+ live(binding) {
155
+ const arbiter = new MyArbiter({ conflictResolver });
156
+ const { state } = binding;
157
+
158
+ return {
159
+ commandEventType: MY_COMMAND,
160
+ actions: MY_ACTIONS,
161
+ parse: (payload) => isMyCommand(payload) ? payload : null,
162
+ snapshot: () => state.toJSON(),
163
+ arbitrate(command, clientId) {
164
+ const admitted = arbiter.admit(command);
165
+ if (admitted === null) {
166
+ return null;
167
+ }
168
+
169
+ return {
170
+ command: admitted,
171
+ commit: () => arbiter.record(admitted)
172
+ };
173
+ }
174
+ };
175
+ }
176
+ ```
177
+
178
+ The room parses the payload, arbitrates it, appends
179
+ `arbitration.command` under `commandEventType`, then calls
180
+ `arbitration.commit` and broadcasts. `commit` runs only after the append
181
+ lands, so a conflict tracker never records a command the store refused. The
182
+ append folds through `apply` before it resolves, so state is current by the
183
+ time peers hear about the change.
184
+
185
+ `broadcast` overrides the default `{ type: "command", data: command }`
186
+ envelope. `voxel-map` uses it to answer a `world-replace` with a full
187
+ snapshot.
188
+
189
+ `actions` names the commands the kind accepts. A configured rights table
190
+ checks each message under `${kind}.${action}`; a payload naming no declared
191
+ action is checked under `${kind}.invalid`.
192
+
193
+ A room that also mutated the state would apply every command twice: once
194
+ itself and once through the fold. Absolute writes survive that, but a command
195
+ carrying a delta does not. `voxel-map`'s `offset-updated` is exactly such a
196
+ command, which is why both shipped kinds keep the halves separate.
197
+
198
+ `createExtension` remains as an escape hatch for a room protocol `live`
199
+ cannot express, and takes precedence over it. It returns a `network.Extension`
200
+ whose `id` must equal the room name.
201
+
202
+ `apply` must never throw. Its event is already persisted, so a fold that
203
+ aborts would break every later replay. Both shipped handlers catch, log and
204
+ keep the last good state.
@@ -0,0 +1,62 @@
1
+ # AssetWriter
2
+
3
+ Use `backend.writer` to change assets. Each method appends an asset lifecycle
4
+ event before the backend updates the catalog and source.
5
+
6
+ ```ts
7
+ writer.create(input: CreateAssetInput): Promise<Result<Event, Error>>
8
+ writer.update(input: UpdateAssetInput): Promise<Result<Event, Error>>
9
+ writer.rename(input: RenameAssetInput): Promise<Result<Event, Error>>
10
+ writer.remove(input: DeleteAssetInput): Promise<Result<Event, Error>>
11
+ ```
12
+
13
+ Every input requires an event-store actor:
14
+
15
+ ```ts
16
+ const actor = { type: "user", id: "alice" } as const;
17
+ ```
18
+
19
+ ## Create
20
+
21
+ ```ts
22
+ const result = await backend.writer.create({
23
+ path: "textures/grass.png",
24
+ data: pngBytes,
25
+ actor
26
+ });
27
+
28
+ const event = result.unwrap();
29
+ console.log(event.assetId);
30
+ ```
31
+
32
+ ```ts
33
+ interface CreateAssetInput {
34
+ path: string;
35
+ data: Uint8Array;
36
+ actor: Actor;
37
+ kind?: string;
38
+ assetId?: string;
39
+ }
40
+ ```
41
+
42
+ The backend generates an asset ID when `assetId` is omitted. It resolves the
43
+ kind from the registered path globs when `kind` is omitted.
44
+
45
+ ## Update, rename and remove
46
+
47
+ ```ts
48
+ await backend.writer.update({ assetId, data: nextBytes, actor });
49
+ await backend.writer.rename({ assetId, to: "textures/ground.png", actor });
50
+ await backend.writer.remove({ assetId, actor });
51
+ ```
52
+
53
+ These operations return an error result when the asset ID is unknown. Paths
54
+ are
55
+ [root-relative POSIX paths](../../asset-source/docs/AssetSource.md#paths); one
56
+ that escapes the
57
+ source root, or that names the `.jollypixel/` state directory, throws
58
+ `AssetPathEscapeError`.
59
+
60
+ Call `backend.flush(assetId)` when the caller must wait for the resulting
61
+ source write. The `alreadyProjected` input option is reserved for source-backed
62
+ reconciliation, where the bytes already exist in the source.
@@ -0,0 +1,71 @@
1
+ # Catalog
2
+
3
+ `CatalogProjection` folds asset lifecycle events into an
4
+ `@jolly-pixel/asset` catalog.
5
+
6
+ ```ts
7
+ const projection = new CatalogProjection({ eventStore });
8
+ projection.load();
9
+ projection.start();
10
+ ```
11
+
12
+ - `load()` folds each asset's newest lifecycle checkpoint and the events
13
+ after it. See [Replay](./Sync.md#replay).
14
+ - `catalog` exposes the current `AssetCatalog`.
15
+ - `size` is the number of cataloged assets.
16
+ - `snapshot()` returns `AssetManifestData`.
17
+ - `changed` is emitted for each recognized lifecycle event applied to the
18
+ catalog. A deleted asset has `record: null`.
19
+ - `close()` stops following appended events and removes listeners.
20
+
21
+ `apply(event)` returns `false` and changes nothing for events outside the
22
+ `asset.` prefix and for lifecycle events whose payload does not match their
23
+ type. See [Typed payloads](./Sync.md#typed-payloads).
24
+
25
+ Each catalog record uses the asset content hash as its `revision`.
26
+
27
+ ## Network room
28
+
29
+ `CatalogExtension` provides a read-only `asset-catalog` room. A client receives
30
+ a snapshot when it joins and catalog changes while it remains connected.
31
+
32
+ ```ts
33
+ server.register(new CatalogExtension({ projection }));
34
+ ```
35
+
36
+ ```ts
37
+ { type: "catalog:snapshot", manifest: AssetManifestData }
38
+ { type: "catalog:changed", change: { eventType, assetId, record } }
39
+ ```
40
+
41
+ `createAssetBackend().attach(server)` registers this room for the usual setup.
42
+
43
+ ## HTTP handler
44
+
45
+ ```ts
46
+ import { createCatalogHandler } from "@jolly-pixel/asset-server/catalog";
47
+
48
+ const handler = createCatalogHandler({
49
+ projection,
50
+ path: "/__jollypixel/catalog"
51
+ });
52
+ ```
53
+
54
+ The default path is `/__jollypixel/catalog`. `GET` returns the JSON snapshot
55
+ and `HEAD` returns the same headers without a body. Other methods on that path
56
+ receive `405` with `Allow: GET, HEAD`. Requests for another path are passed to
57
+ `next()`.
58
+
59
+ ## Vite plugin
60
+
61
+ ```ts
62
+ import {
63
+ createAssetCatalogPlugin
64
+ } from "@jolly-pixel/asset-server/plugins/vite.ts";
65
+
66
+ export default {
67
+ plugins: [createAssetCatalogPlugin({ projection })]
68
+ };
69
+ ```
70
+
71
+ The plugin accepts the same optional `path` override as the HTTP handler.
package/docs/Rooms.md ADDED
@@ -0,0 +1,70 @@
1
+ # Rooms
2
+
3
+ `registerAssetRooms` installs a `network` room resolver that creates one room
4
+ for each open asset.
5
+
6
+ ```ts
7
+ const clearResolver = registerAssetRooms({
8
+ server,
9
+ kinds,
10
+ catalog,
11
+ states,
12
+ projector,
13
+ scheduler,
14
+ graceMs: 30_000
15
+ });
16
+ ```
17
+
18
+ Most hosts call `backend.attach(server)`, which also registers the catalog
19
+ room. The returned callback clears the resolver. It does not evict rooms that
20
+ the server already resolved; `server.close()` disposes those rooms.
21
+
22
+ ## Room names
23
+
24
+ ```ts
25
+ assetRoomName("pixelart", assetId); // "pixelart:<assetId>"
26
+ parseAssetRoomName("pixelart:a:1"); // { kind: "pixelart", assetId: "a:1" }
27
+ ```
28
+
29
+ The first colon separates the kind from the asset ID. Empty kinds, empty IDs
30
+ and names without a colon are rejected.
31
+
32
+ ## Admission
33
+
34
+ A room is created when:
35
+
36
+ - the room name parses as `${kind}:${assetId}`;
37
+ - the kind is registered and provides `live` or `createExtension`;
38
+ - the catalog contains the asset under that kind;
39
+ - the created extension uses the requested room name as its `id`.
40
+
41
+ A kind that provides `live` is hosted by `AssetRoomExtension`, which owns
42
+ the snapshot-on-connect, arbitrate-append-broadcast and rights-event-name
43
+ plumbing. `createExtension` bypasses it and takes precedence.
44
+
45
+ The handler receives the live state through `AssetRoomBinding`:
46
+
47
+ ```ts
48
+ interface AssetRoomBinding<TState> {
49
+ readonly assetId: string;
50
+ readonly kind: string;
51
+ readonly roomId: string;
52
+ readonly state: TState;
53
+ }
54
+ ```
55
+
56
+ ## Eviction
57
+
58
+ The server keeps an empty dynamic room for its configured grace period. A new
59
+ join during that period reuses the room. When the period expires, asset-server
60
+ snapshots pending state, writes it to the source and releases the live state
61
+ before the extension is disposed.
62
+
63
+ `graceMs` overrides the server default for asset rooms. Use
64
+ `server.settled(roomName)` to wait for asynchronous eviction. Closing the
65
+ server evicts all resolved rooms through the same path.
66
+
67
+ Rights use the extension's `name`, which asset handlers normally set to the
68
+ asset kind. This gives every room of one kind the same rights scope.
69
+ `AssetRoomExtension` names each event after the command's `action` when the
70
+ protocol declares it, and `invalid` otherwise.
package/docs/Sync.md ADDED
@@ -0,0 +1,146 @@
1
+ # Sync
2
+
3
+ The event log records asset lifecycle and domain events. The backend projects
4
+ those events to the asset source and catalog. Reconciliation converts external
5
+ source changes into lifecycle events.
6
+
7
+ ## Lifecycle events
8
+
9
+ The `asset.` prefix is reserved for these events:
10
+
11
+ ```ts
12
+ asset.created // { path, kind, hash, size, content }
13
+ asset.updated // { path, kind, hash, size, content }
14
+ asset.renamed // { from, to, kind, hash }
15
+ asset.deleted // { path, kind }
16
+ ```
17
+
18
+ Create and update events store content as base64:
19
+
20
+ ```ts
21
+ type AssetContent =
22
+ | { type: "inline"; encoding: "base64"; data: string }
23
+ | { type: "ref"; hash: string; size: number };
24
+ ```
25
+
26
+ Only inline content is supported. `AssetInlineContent` is that branch alone,
27
+ and it is what a parsed write payload carries, so `decodeContent()` cannot be
28
+ handed a reference. `encodeContent()`, `decodeContent()` and the event
29
+ constants are exported from the main package entrypoint.
30
+
31
+ ### Typed payloads
32
+
33
+ Each payload type is derived from the JSON Schema that validates it, so the
34
+ schema and the type cannot drift. `AssetEventDataMap` binds each event type to
35
+ its payload, and `AssetEvent` is a stored event narrowed to a matching pair:
36
+
37
+ ```ts
38
+ type AssetEventDataMap = {
39
+ "asset.created": AssetWriteData;
40
+ "asset.updated": AssetWriteData;
41
+ "asset.renamed": AssetRenamedData;
42
+ "asset.deleted": AssetDeletedData;
43
+ };
44
+ ```
45
+
46
+ `parseAssetEvent(event)` parses a stored event against that map and returns
47
+ `Result<AssetEvent, AssetEventRejection>`. Readers use it instead of asserting
48
+ a payload shape, because events come back from persistence as parsed JSON.
49
+
50
+ A rejection says which of three things happened, and `describeRejection()`
51
+ renders it for a log:
52
+
53
+ | Reason | Meaning |
54
+ |---|---|
55
+ | `foreign` | another domain's event, or an `asset.` type this version does not know |
56
+ | `malformed` | an asset event whose payload fails its schema; carries the failing paths |
57
+ | `unsupported` | a well-formed write event carrying reference content |
58
+
59
+ Payload schemas accept unknown fields, so an event written by a newer version
60
+ of the backend stays readable rather than being skipped as malformed.
61
+
62
+ A rejected event is skipped rather than folded: the projector keeps the
63
+ asset's last good projection and warns for `malformed` and `unsupported`, and
64
+ the catalog keeps its last good record and returns `false` from `apply`.
65
+ Neither aborts a replay, so one corrupt row cannot stop the backend from
66
+ starting.
67
+
68
+ ## Snapshots
69
+
70
+ Domain events update the live state held by an asset kind handler. The
71
+ `SnapshotScheduler` serializes that state and appends `asset.updated` after the
72
+ configured quiet period, capped by the maximum delay. A snapshot is skipped
73
+ when the serialized bytes have the current content hash.
74
+
75
+ `backend.flush(assetId?)`, room eviction and backend shutdown flush pending
76
+ snapshots. See [Asset kinds](./AssetKinds.md#snapshot-policy) for cadence.
77
+
78
+ ## Replay
79
+
80
+ ```ts
81
+ states.acquire(assetId: string, kind: string): Promise<AssetStateEntry>
82
+ ```
83
+
84
+ Snapshots double as replay checkpoints. `acquire` folds only from the newest
85
+ `asset.created`, `asset.updated` or `asset.deleted`, so replay cost tracks
86
+ edits since the last snapshot rather than the whole history. The fold yields
87
+ periodically, so a long stream cannot hold the event loop while other rooms
88
+ resolve, and concurrent callers share one replay. It re-reads the tail until
89
+ the stream stops growing, because events appended while it yielded land before
90
+ the entry starts following the log.
91
+
92
+ Those three types are exported as `ASSET_CHECKPOINT_EVENT_TYPES`. Loading a
93
+ projection uses the same bound: `AssetProjector.load()` and
94
+ `CatalogProjection.load()` read from each asset's newest checkpoint rather
95
+ than the head of the log, because an older `asset.created` or `asset.updated`
96
+ only produces a projection the replay overwrites. `asset.renamed` is not a
97
+ checkpoint: it folds onto the projection before it, and is read as part of the
98
+ tail. Startup cost therefore tracks the number of assets, not the depth of the
99
+ log. See [Workspace compaction](./Workspace.md#compaction) for removing what
100
+ this skips.
101
+
102
+ ## Reconciliation
103
+
104
+ ```ts
105
+ reconciler.reconcile(): Promise<Result<ReconcileReport, Error>>
106
+
107
+ interface ReconcileReport {
108
+ readonly created: number;
109
+ readonly updated: number;
110
+ readonly renamed: number;
111
+ readonly deleted: number;
112
+ readonly failed: number;
113
+ }
114
+ ```
115
+
116
+ A successful result counts lifecycle events appended during the scan. An
117
+ unreadable entry increments `failed` without stopping other entries. Failure to
118
+ list the source returns an error result for the whole scan.
119
+
120
+ Renames are recognized when one removed path and one added path have the same
121
+ unique content hash. Ambiguous matches are recorded as deletion and creation.
122
+ Byte-identical changes append no event.
123
+
124
+ On a source with `watch()`, `ReconciliationWatcher` groups notifications using
125
+ the configured debounce. Its public controls are:
126
+
127
+ ```ts
128
+ watcher.start(): void
129
+ watcher.notify(path: string): void
130
+ watcher.run(): Promise<void>
131
+ watcher.settle(): Promise<void>
132
+ watcher.close(): Promise<void>
133
+ ```
134
+
135
+ `run()` starts a scan immediately. `settle()` only waits for a scan already in
136
+ progress.
137
+
138
+ ## Projection state
139
+
140
+ `.jollypixel/state.json` stores the last projected event ID for each asset. It
141
+ is machine-local and can be recreated by replaying the event log. Projection
142
+ failures are retained there for inspection and retried by a later flush.
143
+
144
+ `.jollypixel/assets.json` has a different purpose. It maps paths to asset IDs
145
+ for discovery when a checkout has no local event log. Commit this file when
146
+ asset IDs must remain stable across checkouts.