@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/LICENSE +21 -21
- package/README.md +83 -83
- package/dist/catalog/CatalogExtension.d.ts +0 -1
- package/dist/catalog/CatalogExtension.d.ts.map +1 -1
- package/dist/catalog/CatalogExtension.js +0 -3
- package/dist/constants.js +4 -4
- package/dist/rooms/AssetRoomExtension.d.ts +0 -1
- package/dist/rooms/AssetRoomExtension.d.ts.map +1 -1
- package/dist/rooms/AssetRoomExtension.js +0 -3
- package/docs/AssetBackend.md +73 -73
- package/docs/AssetKinds.md +204 -204
- package/docs/AssetWriter.md +62 -62
- package/docs/Catalog.md +71 -71
- package/docs/Rooms.md +70 -70
- package/docs/Sync.md +146 -146
- package/docs/Workspace.md +172 -172
- package/package.json +6 -5
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.
|