@jolly-pixel/asset-server 1.0.0 → 2.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.
package/docs/Rooms.md CHANGED
@@ -1,70 +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.
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 CHANGED
@@ -1,146 +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.
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.