@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.
- package/LICENSE +21 -0
- package/README.md +83 -0
- package/dist/catalog/CatalogExtension.d.ts +37 -0
- package/dist/catalog/CatalogExtension.d.ts.map +1 -0
- package/dist/catalog/CatalogExtension.js +82 -0
- package/dist/catalog/CatalogIdentitySidecar.d.ts +52 -0
- package/dist/catalog/CatalogIdentitySidecar.d.ts.map +1 -0
- package/dist/catalog/CatalogIdentitySidecar.js +132 -0
- package/dist/catalog/CatalogProjection.d.ts +32 -0
- package/dist/catalog/CatalogProjection.d.ts.map +1 -0
- package/dist/catalog/CatalogProjection.js +109 -0
- package/dist/catalog/httpHandler.d.ts +13 -0
- package/dist/catalog/httpHandler.d.ts.map +1 -0
- package/dist/catalog/httpHandler.js +27 -0
- package/dist/catalog/index.d.ts +9 -0
- package/dist/catalog/index.d.ts.map +1 -0
- package/dist/catalog/index.js +4 -0
- package/dist/constants.d.ts +9 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +13 -0
- package/dist/createAssetBackend.d.ts +79 -0
- package/dist/createAssetBackend.d.ts.map +1 -0
- package/dist/createAssetBackend.js +146 -0
- package/dist/events/AssetEvents.d.ts +54 -0
- package/dist/events/AssetEvents.d.ts.map +1 -0
- package/dist/events/AssetEvents.js +102 -0
- package/dist/events/AssetEvents.schema.d.ts +111 -0
- package/dist/events/AssetEvents.schema.d.ts.map +1 -0
- package/dist/events/AssetEvents.schema.js +75 -0
- package/dist/events/index.d.ts +3 -0
- package/dist/events/index.d.ts.map +1 -0
- package/dist/events/index.js +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/kinds/AssetKindHandler.d.ts +38 -0
- package/dist/kinds/AssetKindHandler.d.ts.map +1 -0
- package/dist/kinds/AssetKindHandler.js +1 -0
- package/dist/kinds/AssetKindRegistry.d.ts +14 -0
- package/dist/kinds/AssetKindRegistry.d.ts.map +1 -0
- package/dist/kinds/AssetKindRegistry.js +52 -0
- package/dist/kinds/errors/UnknownAssetKindError.d.ts +5 -0
- package/dist/kinds/errors/UnknownAssetKindError.d.ts.map +1 -0
- package/dist/kinds/errors/UnknownAssetKindError.js +8 -0
- package/dist/kinds/errors/index.d.ts +2 -0
- package/dist/kinds/errors/index.d.ts.map +1 -0
- package/dist/kinds/errors/index.js +1 -0
- package/dist/kinds/handlers/binary.d.ts +10 -0
- package/dist/kinds/handlers/binary.d.ts.map +1 -0
- package/dist/kinds/handlers/binary.js +31 -0
- package/dist/kinds/handlers/index.d.ts +5 -0
- package/dist/kinds/handlers/index.d.ts.map +1 -0
- package/dist/kinds/handlers/index.js +2 -0
- package/dist/kinds/handlers/texture.d.ts +12 -0
- package/dist/kinds/handlers/texture.d.ts.map +1 -0
- package/dist/kinds/handlers/texture.js +27 -0
- package/dist/kinds/index.d.ts +5 -0
- package/dist/kinds/index.d.ts.map +1 -0
- package/dist/kinds/index.js +3 -0
- package/dist/logger.d.ts +4 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +9 -0
- package/dist/plugins/vite.d.ts +36 -0
- package/dist/plugins/vite.d.ts.map +1 -0
- package/dist/plugins/vite.js +60 -0
- package/dist/rooms/AssetRoomExtension.d.ts +29 -0
- package/dist/rooms/AssetRoomExtension.d.ts.map +1 -0
- package/dist/rooms/AssetRoomExtension.js +50 -0
- package/dist/rooms/index.d.ts +5 -0
- package/dist/rooms/index.d.ts.map +1 -0
- package/dist/rooms/index.js +2 -0
- package/dist/rooms/registerAssetRooms.d.ts +21 -0
- package/dist/rooms/registerAssetRooms.d.ts.map +1 -0
- package/dist/rooms/registerAssetRooms.js +71 -0
- package/dist/static/contentTypes.d.ts +6 -0
- package/dist/static/contentTypes.d.ts.map +1 -0
- package/dist/static/contentTypes.js +27 -0
- package/dist/static/httpHandler.d.ts +24 -0
- package/dist/static/httpHandler.d.ts.map +1 -0
- package/dist/static/httpHandler.js +105 -0
- package/dist/static/index.d.ts +4 -0
- package/dist/static/index.d.ts.map +1 -0
- package/dist/static/index.js +2 -0
- package/dist/sync/AssetProjector.d.ts +33 -0
- package/dist/sync/AssetProjector.d.ts.map +1 -0
- package/dist/sync/AssetProjector.js +186 -0
- package/dist/sync/AssetStateStore.d.ts +28 -0
- package/dist/sync/AssetStateStore.d.ts.map +1 -0
- package/dist/sync/AssetStateStore.js +86 -0
- package/dist/sync/AssetWriter.d.ts +54 -0
- package/dist/sync/AssetWriter.d.ts.map +1 -0
- package/dist/sync/AssetWriter.js +149 -0
- package/dist/sync/ProjectionState.d.ts +33 -0
- package/dist/sync/ProjectionState.d.ts.map +1 -0
- package/dist/sync/ProjectionState.js +129 -0
- package/dist/sync/Reconciler.d.ts +36 -0
- package/dist/sync/Reconciler.d.ts.map +1 -0
- package/dist/sync/Reconciler.js +182 -0
- package/dist/sync/ReconciliationWatcher.d.ts +28 -0
- package/dist/sync/ReconciliationWatcher.d.ts.map +1 -0
- package/dist/sync/ReconciliationWatcher.js +86 -0
- package/dist/sync/SnapshotScheduler.d.ts +29 -0
- package/dist/sync/SnapshotScheduler.d.ts.map +1 -0
- package/dist/sync/SnapshotScheduler.js +136 -0
- package/dist/sync/foldProjection.d.ts +12 -0
- package/dist/sync/foldProjection.d.ts.map +1 -0
- package/dist/sync/foldProjection.js +32 -0
- package/dist/sync/index.d.ts +19 -0
- package/dist/sync/index.d.ts.map +1 -0
- package/dist/sync/index.js +9 -0
- package/dist/sync/matchRenames.d.ts +45 -0
- package/dist/sync/matchRenames.d.ts.map +1 -0
- package/dist/sync/matchRenames.js +85 -0
- package/dist/utils/TaskChain.d.ts +9 -0
- package/dist/utils/TaskChain.d.ts.map +1 -0
- package/dist/utils/TaskChain.js +14 -0
- package/dist/utils/contentHash.d.ts +2 -0
- package/dist/utils/contentHash.d.ts.map +1 -0
- package/dist/utils/contentHash.js +7 -0
- package/dist/utils/index.d.ts +3 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +2 -0
- package/dist/workspace/createAssetWorkspace.d.ts +71 -0
- package/dist/workspace/createAssetWorkspace.d.ts.map +1 -0
- package/dist/workspace/createAssetWorkspace.js +72 -0
- package/dist/workspace/index.d.ts +5 -0
- package/dist/workspace/index.d.ts.map +1 -0
- package/dist/workspace/index.js +2 -0
- package/dist/workspace/seedAssetSource.d.ts +5 -0
- package/dist/workspace/seedAssetSource.d.ts.map +1 -0
- package/dist/workspace/seedAssetSource.js +20 -0
- package/docs/AssetBackend.md +73 -0
- package/docs/AssetKinds.md +204 -0
- package/docs/AssetWriter.md +62 -0
- package/docs/Catalog.md +71 -0
- package/docs/Rooms.md +70 -0
- package/docs/Sync.md +146 -0
- package/docs/Workspace.md +172 -0
- 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.
|
package/docs/Catalog.md
ADDED
|
@@ -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.
|