@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,105 @@
1
+ // Import Node.js Dependencies
2
+ import { Buffer } from "node:buffer";
3
+ // Import Third-party Dependencies
4
+ import { ASSET_URL_PREFIX } from "@jolly-pixel/asset";
5
+ import { AssetPathEscapeError, isStatePath, safeAssetPath } from "@jolly-pixel/asset-source";
6
+ import { contentTypesFromKinds, DEFAULT_CONTENT_TYPES, resolveContentType } from "./contentTypes.js";
7
+ // CONSTANTS
8
+ const kRejectionStatus = {
9
+ empty: 404,
10
+ directory: 404,
11
+ invalid: 400,
12
+ absolute: 403,
13
+ traversal: 403,
14
+ reserved: 403
15
+ };
16
+ export const DEFAULT_ASSET_PREFIX = ASSET_URL_PREFIX;
17
+ export function createAssetStaticHandler(options) {
18
+ const { source, kinds, contentTypes } = options;
19
+ const prefix = withTrailingSlash(options.prefix ?? DEFAULT_ASSET_PREFIX);
20
+ const table = {
21
+ ...kinds ? contentTypesFromKinds(kinds) : DEFAULT_CONTENT_TYPES,
22
+ ...contentTypes
23
+ };
24
+ return function assetStaticHandler(request, response, next) {
25
+ const target = request.url ?? "";
26
+ if (!target.startsWith(prefix)) {
27
+ next();
28
+ return;
29
+ }
30
+ if (request.method !== "GET" &&
31
+ request.method !== "HEAD") {
32
+ response.statusCode = 405;
33
+ response.setHeader("allow", "GET, HEAD");
34
+ response.end();
35
+ return;
36
+ }
37
+ const requested = decodeRequestPath(target.slice(prefix.length));
38
+ if (requested === null) {
39
+ end(response, 400);
40
+ return;
41
+ }
42
+ const resolved = safeAssetPath(requested);
43
+ if (!resolved.ok) {
44
+ end(response, kRejectionStatus[resolved.val]);
45
+ return;
46
+ }
47
+ const assetPath = resolved.val;
48
+ if (isHidden(source, assetPath)) {
49
+ end(response, 404);
50
+ return;
51
+ }
52
+ void serve(source, assetPath, resolveContentType(assetPath, table), request, response);
53
+ };
54
+ }
55
+ async function serve(source, assetPath, contentType, request, response) {
56
+ let bytes;
57
+ try {
58
+ bytes = await source.read(assetPath);
59
+ }
60
+ catch (error) {
61
+ // The source rejects what its own containment check refuses, a symlink
62
+ // out of the root above all.
63
+ if (error instanceof AssetPathEscapeError) {
64
+ end(response, 403);
65
+ return;
66
+ }
67
+ end(response, isMissing(error) ? 404 : 500);
68
+ return;
69
+ }
70
+ response.statusCode = 200;
71
+ response.setHeader("content-type", contentType);
72
+ response.setHeader("content-length", String(bytes.byteLength));
73
+ response.end(request.method === "HEAD" ? undefined : Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength));
74
+ }
75
+ function decodeRequestPath(target) {
76
+ const separator = target.search(/[?#]/);
77
+ const encoded = separator === -1 ?
78
+ target :
79
+ target.slice(0, separator);
80
+ try {
81
+ return decodeURIComponent(encoded);
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ }
87
+ function isHidden(source, assetPath) {
88
+ return isStatePath(assetPath) ||
89
+ (source.isIgnored?.(assetPath) ?? false);
90
+ }
91
+ function isMissing(error) {
92
+ return typeof error === "object" &&
93
+ error !== null &&
94
+ "code" in error &&
95
+ (error.code === "ENOENT" ||
96
+ error.code === "EISDIR" ||
97
+ error.code === "ENOTDIR");
98
+ }
99
+ function end(response, statusCode) {
100
+ response.statusCode = statusCode;
101
+ response.end();
102
+ }
103
+ function withTrailingSlash(prefix) {
104
+ return prefix.endsWith("/") ? prefix : `${prefix}/`;
105
+ }
@@ -0,0 +1,4 @@
1
+ export { createAssetStaticHandler, DEFAULT_ASSET_PREFIX } from "./httpHandler.ts";
2
+ export type { AssetStaticHandler, AssetStaticHandlerOptions } from "./httpHandler.ts";
3
+ export { contentTypesFromKinds, resolveContentType, DEFAULT_CONTENT_TYPE, DEFAULT_CONTENT_TYPES } from "./contentTypes.ts";
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/static/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,wBAAwB,EACxB,oBAAoB,EACrB,MAAM,kBAAkB,CAAC;AAC1B,YAAY,EACV,kBAAkB,EAClB,yBAAyB,EAC1B,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EACL,qBAAqB,EACrB,kBAAkB,EAClB,oBAAoB,EACpB,qBAAqB,EACtB,MAAM,mBAAmB,CAAC"}
@@ -0,0 +1,2 @@
1
+ export { createAssetStaticHandler, DEFAULT_ASSET_PREFIX } from "./httpHandler.js";
2
+ export { contentTypesFromKinds, resolveContentType, DEFAULT_CONTENT_TYPE, DEFAULT_CONTENT_TYPES } from "./contentTypes.js";
@@ -0,0 +1,33 @@
1
+ import type * as EventStore from "@jolly-pixel/event-store";
2
+ import type { AssetSource } from "@jolly-pixel/asset-source";
3
+ import { type Logger } from "../logger.ts";
4
+ import { type AssetProjection } from "./foldProjection.ts";
5
+ import type { ProjectionState } from "./ProjectionState.ts";
6
+ export interface AssetProjectorOptions {
7
+ source: AssetSource;
8
+ eventStore: EventStore.EventStore;
9
+ state: ProjectionState;
10
+ logger?: Logger;
11
+ }
12
+ /**
13
+ * Projects each asset's folded event stream into physical storage.
14
+ *
15
+ * Idempotent writes make stale checkpoints safe to replay.
16
+ */
17
+ export declare class AssetProjector {
18
+ #private;
19
+ constructor(options: AssetProjectorOptions);
20
+ load(): void;
21
+ start(): void;
22
+ close(): Promise<void>;
23
+ projected(assetId: string): AssetProjection | null;
24
+ projections(): IterableIterator<{
25
+ assetId: string;
26
+ projection: AssetProjection;
27
+ }>;
28
+ desired(assetId: string): AssetProjection | null;
29
+ get pending(): number;
30
+ markProjected(assetId: string): void;
31
+ flush(assetId?: string): Promise<void>;
32
+ }
33
+ //# sourceMappingURL=AssetProjector.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"AssetProjector.d.ts","sourceRoot":"","sources":["../../src/sync/AssetProjector.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,KAAK,UAAU,MAAM,0BAA0B,CAAC;AAC5D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAC;AAG7D,OAAO,EAEL,KAAK,MAAM,EACZ,MAAM,cAAc,CAAC;AAWtB,OAAO,EAEL,KAAK,eAAe,EACrB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAS5D,MAAM,WAAW,qBAAqB;IACpC,MAAM,EAAE,WAAW,CAAC;IACpB,UAAU,EAAE,UAAU,CAAC,UAAU,CAAC;IAClC,KAAK,EAAE,eAAe,CAAC;IACvB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;GAIG;AACH,qBAAa,cAAc;;gBAavB,OAAO,EAAE,qBAAqB;IAQhC,IAAI,IAAI,IAAI;IAaZ,KAAK,IAAI,IAAI;IAUP,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAO5B,SAAS,CACP,OAAO,EAAE,MAAM,GACd,eAAe,GAAG,IAAI;IAIvB,WAAW,IAAI,gBAAgB,CAAC;QAChC,OAAO,EAAE,MAAM,CAAC;QAChB,UAAU,EAAE,eAAe,CAAC;KAC7B,CAAC;IAQF,OAAO,CACL,OAAO,EAAE,MAAM,GACd,eAAe,GAAG,IAAI;IAIzB,IAAI,OAAO,IAAI,MAAM,CAEpB;IAED,aAAa,CACX,OAAO,EAAE,MAAM,GACd,IAAI;IAmBP,KAAK,CACH,OAAO,CAAC,EAAE,MAAM,GACf,OAAO,CAAC,IAAI,CAAC;CA8IjB"}
@@ -0,0 +1,186 @@
1
+ // Import Internal Dependencies
2
+ import { silentLogger } from "../logger.js";
3
+ import { ASSET_EVENT_PREFIX } from "../constants.js";
4
+ import { ASSET_CHECKPOINT_EVENT_TYPES, decodeContent, describeRejection, parseAssetEvent } from "../events/AssetEvents.js";
5
+ import { applyProjection } from "./foldProjection.js";
6
+ import { TaskChain } from "../utils/TaskChain.js";
7
+ /**
8
+ * Projects each asset's folded event stream into physical storage.
9
+ *
10
+ * Idempotent writes make stale checkpoints safe to replay.
11
+ */
12
+ export class AssetProjector {
13
+ #source;
14
+ #eventStore;
15
+ #state;
16
+ #logger;
17
+ #folds = new Map();
18
+ #dirty = new Set();
19
+ #stateDirty = false;
20
+ #queue = new TaskChain();
21
+ #unsubscribe = null;
22
+ constructor(options) {
23
+ this.#source = options.source;
24
+ this.#eventStore = options.eventStore;
25
+ this.#state = options.state;
26
+ this.#logger = options.logger ?? silentLogger();
27
+ }
28
+ load() {
29
+ this.#folds.clear();
30
+ this.#dirty.clear();
31
+ const events = this.#eventStore.reader.listFromCheckpoints({
32
+ checkpointEventTypes: ASSET_CHECKPOINT_EVENT_TYPES,
33
+ eventTypePrefix: ASSET_EVENT_PREFIX
34
+ });
35
+ for (const event of events) {
36
+ this.#absorb(event);
37
+ }
38
+ }
39
+ start() {
40
+ this.#unsubscribe ??= this.#eventStore.subscribe((event) => {
41
+ this.#absorb(event);
42
+ void this.flush(event.assetId);
43
+ }, { eventTypePrefix: ASSET_EVENT_PREFIX });
44
+ }
45
+ async close() {
46
+ this.#unsubscribe?.();
47
+ this.#unsubscribe = null;
48
+ await this.flush();
49
+ }
50
+ projected(assetId) {
51
+ return this.#folds.get(assetId)?.projected ?? null;
52
+ }
53
+ *projections() {
54
+ for (const [assetId, fold] of this.#folds) {
55
+ if (fold.projected !== null) {
56
+ yield { assetId, projection: fold.projected };
57
+ }
58
+ }
59
+ }
60
+ desired(assetId) {
61
+ return this.#folds.get(assetId)?.desired ?? null;
62
+ }
63
+ get pending() {
64
+ return this.#dirty.size;
65
+ }
66
+ markProjected(assetId) {
67
+ const fold = this.#folds.get(assetId);
68
+ if (fold === undefined) {
69
+ this.#logger
70
+ .withMetadata({ assetId })
71
+ .warn("asset marked projected before its event was absorbed; " +
72
+ "is the projector started?");
73
+ return;
74
+ }
75
+ fold.projected = fold.desired;
76
+ this.#dirty.delete(assetId);
77
+ this.#state.advance(assetId, fold.desiredEventId);
78
+ this.#stateDirty = true;
79
+ }
80
+ flush(assetId) {
81
+ return this.#queue.run(() => this.#converge(assetId));
82
+ }
83
+ #absorb(event) {
84
+ const parsed = parseAssetEvent(event);
85
+ if (!parsed.ok) {
86
+ this.#reject(event, parsed.val);
87
+ return;
88
+ }
89
+ const assetEvent = parsed.val;
90
+ const fold = this.#folds.get(event.assetId) ?? {
91
+ projected: null,
92
+ desired: null,
93
+ desiredEventId: 0
94
+ };
95
+ fold.desired = applyProjection(fold.desired, assetEvent);
96
+ fold.desiredEventId = event.eventId;
97
+ if (event.eventId <= this.#state.checkpoint(event.assetId)) {
98
+ fold.projected = fold.desired;
99
+ }
100
+ else {
101
+ this.#dirty.add(event.assetId);
102
+ }
103
+ this.#folds.set(event.assetId, fold);
104
+ }
105
+ #reject(event, rejection) {
106
+ const log = this.#logger.withMetadata({
107
+ assetId: event.assetId,
108
+ eventId: event.eventId,
109
+ eventType: event.eventType,
110
+ reason: rejection.reason,
111
+ detail: describeRejection(rejection)
112
+ });
113
+ if (rejection.reason === "foreign") {
114
+ log.debug("unknown asset event skipped");
115
+ return;
116
+ }
117
+ log.warn("asset event skipped");
118
+ }
119
+ async #converge(assetId) {
120
+ const targets = assetId === undefined ?
121
+ [...this.#dirty] :
122
+ [assetId].filter((id) => this.#dirty.has(id));
123
+ for (const target of targets) {
124
+ await this.#convergeAsset(target);
125
+ }
126
+ if (this.#stateDirty) {
127
+ this.#stateDirty = false;
128
+ try {
129
+ await this.#state.save();
130
+ }
131
+ catch (error) {
132
+ this.#logger
133
+ .withMetadata({
134
+ reason: error instanceof Error ? error.message : String(error)
135
+ })
136
+ .warn("projection state not persisted");
137
+ }
138
+ }
139
+ }
140
+ async #convergeAsset(assetId) {
141
+ const fold = this.#folds.get(assetId);
142
+ if (fold === undefined) {
143
+ this.#dirty.delete(assetId);
144
+ return;
145
+ }
146
+ const { projected, desired, desiredEventId } = fold;
147
+ try {
148
+ await this.#applyOperations(projected, desired);
149
+ }
150
+ catch (error) {
151
+ const reason = error instanceof Error ? error.message : String(error);
152
+ this.#state.recordFailure(assetId, desiredEventId, reason);
153
+ this.#stateDirty = true;
154
+ this.#logger
155
+ .withMetadata({ assetId, eventId: desiredEventId, reason })
156
+ .error("asset projection failed");
157
+ return;
158
+ }
159
+ fold.projected = desired;
160
+ this.#dirty.delete(assetId);
161
+ this.#state.advance(assetId, desiredEventId);
162
+ this.#stateDirty = true;
163
+ this.#logger
164
+ .withMetadata({
165
+ assetId,
166
+ eventId: desiredEventId,
167
+ path: desired?.path ?? projected?.path
168
+ })
169
+ .debug("asset projected");
170
+ }
171
+ async #applyOperations(projected, desired) {
172
+ if (desired === null) {
173
+ if (projected !== null) {
174
+ await this.#source.delete(projected.path);
175
+ }
176
+ return;
177
+ }
178
+ const moved = projected !== null && projected.path !== desired.path;
179
+ if (projected === null || moved || projected.hash !== desired.hash) {
180
+ await this.#source.write(desired.path, decodeContent(desired.content));
181
+ }
182
+ if (moved) {
183
+ await this.#source.delete(projected.path);
184
+ }
185
+ }
186
+ }
@@ -0,0 +1,28 @@
1
+ import type * as EventStore from "@jolly-pixel/event-store";
2
+ import type { AssetKindRegistry } from "../kinds/AssetKindRegistry.ts";
3
+ import type { AssetKindHandler } from "../kinds/AssetKindHandler.ts";
4
+ export interface AssetStateEntry {
5
+ readonly assetId: string;
6
+ readonly kind: string;
7
+ readonly handler: AssetKindHandler;
8
+ readonly state: unknown;
9
+ }
10
+ export interface AssetStateStoreOptions {
11
+ eventStore: EventStore.EventStore;
12
+ kinds: AssetKindRegistry;
13
+ }
14
+ /**
15
+ * Holds live per-asset state folded by each asset's kind handler.
16
+ */
17
+ export declare class AssetStateStore {
18
+ #private;
19
+ constructor(options: AssetStateStoreOptions);
20
+ start(): void;
21
+ close(): void;
22
+ has(assetId: string): boolean;
23
+ get(assetId: string): AssetStateEntry | undefined;
24
+ acquire(assetId: string, kind: string): Promise<AssetStateEntry>;
25
+ release(assetId: string): void;
26
+ serialize(assetId: string, kind: string): Promise<Uint8Array>;
27
+ }
28
+ //# sourceMappingURL=AssetStateStore.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"AssetStateStore.d.ts","sourceRoot":"","sources":["../../src/sync/AssetStateStore.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,KAAK,UAAU,MAAM,0BAA0B,CAAC;AAG5D,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,+BAA+B,CAAC;AACvE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,8BAA8B,CAAC;AAMrE,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,gBAAgB,CAAC;IACnC,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;CACzB;AAED,MAAM,WAAW,sBAAsB;IACrC,UAAU,EAAE,UAAU,CAAC,UAAU,CAAC;IAClC,KAAK,EAAE,iBAAiB,CAAC;CAC1B;AAED;;GAEG;AACH,qBAAa,eAAe;;gBAQxB,OAAO,EAAE,sBAAsB;IAMjC,KAAK,IAAI,IAAI;IAOb,KAAK,IAAI,IAAI;IAMb,GAAG,CACD,OAAO,EAAE,MAAM,GACd,OAAO;IAIV,GAAG,CACD,OAAO,EAAE,MAAM,GACd,eAAe,GAAG,SAAS;IAI9B,OAAO,CACL,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,eAAe,CAAC;IAuB3B,OAAO,CACL,OAAO,EAAE,MAAM,GACd,IAAI;IAID,SAAS,CACb,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,UAAU,CAAC;CAwCvB"}
@@ -0,0 +1,86 @@
1
+ // Import Node.js Dependencies
2
+ import timers from "node:timers/promises";
3
+ import { ASSET_CHECKPOINT_EVENT_TYPES } from "../events/AssetEvents.js";
4
+ // CONSTANTS
5
+ const kReplayYieldEvery = 250;
6
+ /**
7
+ * Holds live per-asset state folded by each asset's kind handler.
8
+ */
9
+ export class AssetStateStore {
10
+ #eventStore;
11
+ #kinds;
12
+ #entries = new Map();
13
+ #replays = new Map();
14
+ #unsubscribe = null;
15
+ constructor(options) {
16
+ this.#eventStore = options.eventStore;
17
+ this.#kinds = options.kinds;
18
+ }
19
+ start() {
20
+ this.#unsubscribe ??= this.#eventStore.subscribe((event) => {
21
+ const entry = this.#entries.get(event.assetId);
22
+ entry?.handler.apply(entry.state, event);
23
+ });
24
+ }
25
+ close() {
26
+ this.#unsubscribe?.();
27
+ this.#unsubscribe = null;
28
+ this.#entries.clear();
29
+ }
30
+ has(assetId) {
31
+ return this.#entries.has(assetId);
32
+ }
33
+ get(assetId) {
34
+ return this.#entries.get(assetId);
35
+ }
36
+ acquire(assetId, kind) {
37
+ const existing = this.#entries.get(assetId);
38
+ if (existing !== undefined) {
39
+ return Promise.resolve(existing);
40
+ }
41
+ const inFlight = this.#replays.get(assetId);
42
+ if (inFlight !== undefined) {
43
+ return inFlight;
44
+ }
45
+ const replay = this.#replay(assetId, kind)
46
+ .then((entry) => {
47
+ this.#entries.set(assetId, entry);
48
+ return entry;
49
+ })
50
+ .finally(() => this.#replays.delete(assetId));
51
+ this.#replays.set(assetId, replay);
52
+ return replay;
53
+ }
54
+ release(assetId) {
55
+ this.#entries.delete(assetId);
56
+ }
57
+ async serialize(assetId, kind) {
58
+ const entry = this.#entries.get(assetId) ??
59
+ await this.#replay(assetId, kind);
60
+ return entry.handler.serialize(entry.state);
61
+ }
62
+ async #replay(assetId, kind) {
63
+ const handler = this.#kinds.get(kind);
64
+ const state = handler.create(assetId);
65
+ let events = this.#eventStore.reader.listFromCheckpoint(assetId, ASSET_CHECKPOINT_EVENT_TYPES);
66
+ let from = 0;
67
+ let sinceYield = 0;
68
+ while (events.length > 0) {
69
+ for (const event of events) {
70
+ handler.apply(state, event);
71
+ from = event.eventVersion;
72
+ if (++sinceYield >= kReplayYieldEvery) {
73
+ sinceYield = 0;
74
+ await timers.setImmediate();
75
+ }
76
+ }
77
+ events = this.#eventStore.reader.list(assetId, from);
78
+ }
79
+ return {
80
+ assetId,
81
+ kind,
82
+ handler,
83
+ state
84
+ };
85
+ }
86
+ }
@@ -0,0 +1,54 @@
1
+ import type * as EventStore from "@jolly-pixel/event-store";
2
+ import { type Result } from "@openally/result";
3
+ import { type AssetSource } from "@jolly-pixel/asset-source";
4
+ import type { AssetKindRegistry } from "../kinds/AssetKindRegistry.ts";
5
+ import { CatalogIdentitySidecar } from "../catalog/CatalogIdentitySidecar.ts";
6
+ import { type AssetEventDataMap } from "../events/AssetEvents.ts";
7
+ import type { AssetProjector } from "./AssetProjector.ts";
8
+ import { type Logger } from "../logger.ts";
9
+ export interface AssetWriterOptions {
10
+ eventStore: EventStore.TypedEventStore<AssetEventDataMap>;
11
+ kinds: AssetKindRegistry;
12
+ projector: AssetProjector;
13
+ identity: CatalogIdentitySidecar;
14
+ source: AssetSource;
15
+ logger?: Logger;
16
+ }
17
+ interface WriteOptions {
18
+ actor: EventStore.Actor;
19
+ /**
20
+ * Marks source-backed mutations as projected without another write.
21
+ */
22
+ alreadyProjected?: boolean;
23
+ }
24
+ export interface CreateAssetInput extends WriteOptions {
25
+ path: string;
26
+ data: Uint8Array;
27
+ kind?: string;
28
+ assetId?: string;
29
+ }
30
+ export interface UpdateAssetInput extends WriteOptions {
31
+ assetId: string;
32
+ data: Uint8Array;
33
+ }
34
+ export interface RenameAssetInput extends WriteOptions {
35
+ assetId: string;
36
+ to: string;
37
+ }
38
+ export interface DeleteAssetInput extends WriteOptions {
39
+ assetId: string;
40
+ }
41
+ /**
42
+ * Appends lifecycle events before updating projections and identity.
43
+ */
44
+ export declare class AssetWriter {
45
+ #private;
46
+ constructor(options: AssetWriterOptions);
47
+ get identity(): CatalogIdentitySidecar;
48
+ create(input: CreateAssetInput): Promise<Result<EventStore.Event, Error>>;
49
+ update(input: UpdateAssetInput): Promise<Result<EventStore.Event, Error>>;
50
+ rename(input: RenameAssetInput): Promise<Result<EventStore.Event, Error>>;
51
+ remove(input: DeleteAssetInput): Promise<Result<EventStore.Event, Error>>;
52
+ }
53
+ export {};
54
+ //# sourceMappingURL=AssetWriter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"AssetWriter.d.ts","sourceRoot":"","sources":["../../src/sync/AssetWriter.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,KAAK,UAAU,MAAM,0BAA0B,CAAC;AAC5D,OAAO,EAGL,KAAK,MAAM,EACZ,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAIL,KAAK,WAAW,EACjB,MAAM,2BAA2B,CAAC;AAGnC,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,+BAA+B,CAAC;AACvE,OAAO,EAAE,sBAAsB,EAAE,MAAM,sCAAsC,CAAC;AAE9E,OAAO,EAML,KAAK,iBAAiB,EACvB,MAAM,0BAA0B,CAAC;AAClC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAE1D,OAAO,EAEL,KAAK,MAAM,EACZ,MAAM,cAAc,CAAC;AAEtB,MAAM,WAAW,kBAAkB;IACjC,UAAU,EAAE,UAAU,CAAC,eAAe,CAAC,iBAAiB,CAAC,CAAC;IAC1D,KAAK,EAAE,iBAAiB,CAAC;IACzB,SAAS,EAAE,cAAc,CAAC;IAC1B,QAAQ,EAAE,sBAAsB,CAAC;IACjC,MAAM,EAAE,WAAW,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,UAAU,YAAY;IACpB,KAAK,EAAE,UAAU,CAAC,KAAK,CAAC;IACxB;;OAEG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B;AAED,MAAM,WAAW,gBAAiB,SAAQ,YAAY;IACpD,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,UAAU,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,gBAAiB,SAAQ,YAAY;IACpD,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,UAAU,CAAC;CAClB;AAED,MAAM,WAAW,gBAAiB,SAAQ,YAAY;IACpD,OAAO,EAAE,MAAM,CAAC;IAChB,EAAE,EAAE,MAAM,CAAC;CACZ;AAED,MAAM,WAAW,gBAAiB,SAAQ,YAAY;IACpD,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;GAEG;AACH,qBAAa,WAAW;;gBASpB,OAAO,EAAE,kBAAkB;IAU7B,IAAI,QAAQ,IAAI,sBAAsB,CAErC;IAEK,MAAM,CACV,KAAK,EAAE,gBAAgB,GACtB,OAAO,CAAC,MAAM,CAAC,UAAU,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;IA8B3C,MAAM,CACJ,KAAK,EAAE,gBAAgB,GACtB,OAAO,CAAC,MAAM,CAAC,UAAU,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;IAyBrC,MAAM,CACV,KAAK,EAAE,gBAAgB,GACtB,OAAO,CAAC,MAAM,CAAC,UAAU,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;IAoCrC,MAAM,CACV,KAAK,EAAE,gBAAgB,GACtB,OAAO,CAAC,MAAM,CAAC,UAAU,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;CA0E5C"}
@@ -0,0 +1,149 @@
1
+ // Import Node.js Dependencies
2
+ import { randomUUID } from "node:crypto";
3
+ import { Err, Ok } from "@openally/result";
4
+ import { AssetPathEscapeError, isStatePath, normalizeAssetPath } from "@jolly-pixel/asset-source";
5
+ import { CatalogIdentitySidecar } from "../catalog/CatalogIdentitySidecar.js";
6
+ import { contentHash } from "../utils/contentHash.js";
7
+ import { ASSET_CREATED, ASSET_DELETED, ASSET_RENAMED, ASSET_UPDATED, encodeContent } from "../events/AssetEvents.js";
8
+ import { silentLogger } from "../logger.js";
9
+ /**
10
+ * Appends lifecycle events before updating projections and identity.
11
+ */
12
+ export class AssetWriter {
13
+ #eventStore;
14
+ #kinds;
15
+ #projector;
16
+ #identity;
17
+ #source;
18
+ #logger;
19
+ constructor(options) {
20
+ this.#eventStore = options.eventStore;
21
+ this.#kinds = options.kinds;
22
+ this.#projector = options.projector;
23
+ this.#identity = options.identity;
24
+ this.#source = options.source;
25
+ this.#logger = options.logger ?? silentLogger();
26
+ }
27
+ get identity() {
28
+ return this.#identity;
29
+ }
30
+ async create(input) {
31
+ const path = writableAssetPath(input.path);
32
+ const kind = input.kind ?? this.#kinds.resolve(path).kind;
33
+ const assetId = input.assetId ?? randomUUID();
34
+ const assetData = {
35
+ path,
36
+ kind,
37
+ hash: contentHash(input.data),
38
+ size: input.data.byteLength,
39
+ content: encodeContent(input.data)
40
+ };
41
+ const appended = this.#append(assetId, kind, ASSET_CREATED, assetData, input);
42
+ if (!appended.ok) {
43
+ return appended;
44
+ }
45
+ this.#identity.set({
46
+ id: assetId,
47
+ path,
48
+ kind
49
+ });
50
+ await this.#saveIdentity();
51
+ return appended;
52
+ }
53
+ update(input) {
54
+ const current = this.#current(input.assetId);
55
+ if (!current.ok) {
56
+ return Promise.resolve(current);
57
+ }
58
+ const updatedAssetData = {
59
+ path: current.val.path,
60
+ kind: current.val.kind,
61
+ hash: contentHash(input.data),
62
+ size: input.data.byteLength,
63
+ content: encodeContent(input.data)
64
+ };
65
+ return Promise.resolve(this.#append(input.assetId, current.val.kind, ASSET_UPDATED, updatedAssetData, input));
66
+ }
67
+ async rename(input) {
68
+ const found = this.#current(input.assetId);
69
+ if (!found.ok) {
70
+ return found;
71
+ }
72
+ const current = found.val;
73
+ const to = writableAssetPath(input.to);
74
+ const renamedAssetData = {
75
+ from: current.path,
76
+ to,
77
+ kind: current.kind,
78
+ hash: current.hash
79
+ };
80
+ const appended = this.#append(input.assetId, current.kind, ASSET_RENAMED, renamedAssetData, input);
81
+ if (!appended.ok) {
82
+ return appended;
83
+ }
84
+ this.#identity.set({
85
+ id: input.assetId,
86
+ path: to,
87
+ kind: current.kind
88
+ });
89
+ await this.#saveIdentity();
90
+ return appended;
91
+ }
92
+ async remove(input) {
93
+ const found = this.#current(input.assetId);
94
+ if (!found.ok) {
95
+ return found;
96
+ }
97
+ const current = found.val;
98
+ const deletedAssetData = {
99
+ path: current.path,
100
+ kind: current.kind
101
+ };
102
+ const appended = this.#append(input.assetId, current.kind, ASSET_DELETED, deletedAssetData, input);
103
+ if (!appended.ok) {
104
+ return appended;
105
+ }
106
+ this.#identity.removeById(input.assetId);
107
+ await this.#saveIdentity();
108
+ return appended;
109
+ }
110
+ #current(assetId) {
111
+ const current = this.#projector.desired(assetId);
112
+ return current === null ?
113
+ Err(new Error(`Unknown asset "${assetId}".`)) :
114
+ Ok(current);
115
+ }
116
+ async #saveIdentity() {
117
+ try {
118
+ await this.#identity.save(this.#source);
119
+ }
120
+ catch (error) {
121
+ this.#logger
122
+ .withMetadata({
123
+ reason: error instanceof Error ? error.message : String(error)
124
+ })
125
+ .warn("identity sidecar not persisted");
126
+ }
127
+ }
128
+ #append(assetId, kind, eventType, eventData, options) {
129
+ const result = this.#eventStore.writer.append({
130
+ assetType: kind,
131
+ assetId,
132
+ eventType,
133
+ eventData,
134
+ actor: options.actor
135
+ });
136
+ if (result.ok &&
137
+ options.alreadyProjected === true) {
138
+ this.#projector.markProjected(assetId);
139
+ }
140
+ return result;
141
+ }
142
+ }
143
+ function writableAssetPath(input) {
144
+ const path = normalizeAssetPath(input);
145
+ if (isStatePath(path)) {
146
+ throw new AssetPathEscapeError(input, "reserved");
147
+ }
148
+ return path;
149
+ }