@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,33 @@
1
+ import { type AssetSource } from "@jolly-pixel/asset-source";
2
+ import { type Logger } from "../logger.ts";
3
+ export interface ProjectionFailure {
4
+ readonly eventId: number;
5
+ readonly attempts: number;
6
+ readonly reason: string;
7
+ }
8
+ export interface ProjectionStateParse {
9
+ readonly data: ProjectionStateData | null;
10
+ readonly dropped: number;
11
+ }
12
+ export interface ProjectionStateData {
13
+ readonly version: 1;
14
+ readonly checkpoints: Record<string, number>;
15
+ readonly failures: Record<string, ProjectionFailure>;
16
+ }
17
+ /**
18
+ * Persists each asset's last successfully projected event id.
19
+ */
20
+ export declare class ProjectionState {
21
+ #private;
22
+ constructor(source: AssetSource, data?: ProjectionStateData | null);
23
+ checkpoint(assetId: string): number;
24
+ failure(assetId: string): ProjectionFailure | undefined;
25
+ advance(assetId: string, eventId: number): void;
26
+ recordFailure(assetId: string, eventId: number, reason: string): void;
27
+ forget(assetId: string): void;
28
+ toJSON(): ProjectionStateData;
29
+ save(): Promise<void>;
30
+ static load(source: AssetSource, logger?: Logger): Promise<ProjectionState>;
31
+ static parse(input: unknown): ProjectionStateParse;
32
+ }
33
+ //# sourceMappingURL=ProjectionState.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ProjectionState.d.ts","sourceRoot":"","sources":["../../src/sync/ProjectionState.ts"],"names":[],"mappings":"AAKA,OAAO,EAGL,KAAK,WAAW,EACjB,MAAM,2BAA2B,CAAC;AAGnC,OAAO,EAEL,KAAK,MAAM,EACZ,MAAM,cAAc,CAAC;AA+BtB,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,IAAI,EAAE,mBAAmB,GAAG,IAAI,CAAC;IAC1C,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAOD,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,OAAO,EAAE,CAAC,CAAC;IACpB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC7C,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAC;CACtD;AAED;;GAEG;AACH,qBAAa,eAAe;;gBAMxB,MAAM,EAAE,WAAW,EACnB,IAAI,GAAE,mBAAmB,GAAG,IAAW;IAqBzC,UAAU,CACR,OAAO,EAAE,MAAM,GACd,MAAM;IAIT,OAAO,CACL,OAAO,EAAE,MAAM,GACd,iBAAiB,GAAG,SAAS;IAIhC,OAAO,CACL,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,GACd,IAAI;IAKP,aAAa,CACX,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,GACb,IAAI;IASP,MAAM,CACJ,OAAO,EAAE,MAAM,GACd,IAAI;IAKP,MAAM,IAAI,mBAAmB;IAgB7B,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;WAQR,IAAI,CACf,MAAM,EAAE,WAAW,EACnB,MAAM,GAAE,MAAuB,GAC9B,OAAO,CAAC,eAAe,CAAC;IAmB3B,MAAM,CAAC,KAAK,CACV,KAAK,EAAE,OAAO,GACb,oBAAoB;CA0BxB"}
@@ -0,0 +1,129 @@
1
+ // Import Third-party Dependencies
2
+ import { defineSchema, Validator } from "ata-validator";
3
+ import { readJsonFile, writeJsonFile } from "@jolly-pixel/asset-source";
4
+ // Import Internal Dependencies
5
+ import { silentLogger } from "../logger.js";
6
+ import { PROJECTION_STATE_PATH } from "../constants.js";
7
+ // CONSTANTS
8
+ const kStateVersion = 1;
9
+ const kValidatorOptions = { useDefaults: false };
10
+ const kRecordValidator = new Validator(defineSchema({ type: "object" }), kValidatorOptions);
11
+ const kCheckpointValidator = new Validator(defineSchema({ type: "integer" }), kValidatorOptions);
12
+ const kFailureValidator = new Validator(defineSchema({
13
+ type: "object",
14
+ properties: {
15
+ eventId: { type: "integer" },
16
+ attempts: { type: "integer" },
17
+ reason: { type: "string" }
18
+ },
19
+ required: [
20
+ "eventId",
21
+ "attempts",
22
+ "reason"
23
+ ]
24
+ }), kValidatorOptions);
25
+ /**
26
+ * Persists each asset's last successfully projected event id.
27
+ */
28
+ export class ProjectionState {
29
+ #checkpoints = new Map();
30
+ #failures = new Map();
31
+ #source;
32
+ constructor(source, data = null) {
33
+ this.#source = source;
34
+ if (data === null) {
35
+ return;
36
+ }
37
+ for (const [assetId, eventId] of Object.entries(data.checkpoints)) {
38
+ this.#checkpoints.set(assetId, eventId);
39
+ }
40
+ for (const [assetId, failure] of Object.entries(data.failures)) {
41
+ this.#failures.set(assetId, failure);
42
+ }
43
+ }
44
+ checkpoint(assetId) {
45
+ return this.#checkpoints.get(assetId) ?? 0;
46
+ }
47
+ failure(assetId) {
48
+ return this.#failures.get(assetId);
49
+ }
50
+ advance(assetId, eventId) {
51
+ this.#checkpoints.set(assetId, eventId);
52
+ this.#failures.delete(assetId);
53
+ }
54
+ recordFailure(assetId, eventId, reason) {
55
+ const previous = this.#failures.get(assetId);
56
+ this.#failures.set(assetId, {
57
+ eventId,
58
+ attempts: previous?.eventId === eventId ? previous.attempts + 1 : 1,
59
+ reason
60
+ });
61
+ }
62
+ forget(assetId) {
63
+ this.#checkpoints.delete(assetId);
64
+ this.#failures.delete(assetId);
65
+ }
66
+ toJSON() {
67
+ return {
68
+ version: kStateVersion,
69
+ checkpoints: Object.fromEntries([...this.#checkpoints.entries()].sort(([a], [b]) => a.localeCompare(b))),
70
+ failures: Object.fromEntries([...this.#failures.entries()].sort(([a], [b]) => a.localeCompare(b)))
71
+ };
72
+ }
73
+ save() {
74
+ return writeJsonFile(this.#source, PROJECTION_STATE_PATH, this.toJSON());
75
+ }
76
+ static async load(source, logger = silentLogger()) {
77
+ const { data, dropped } = ProjectionState.parse(await readJsonFile(source, PROJECTION_STATE_PATH));
78
+ if (dropped > 0) {
79
+ logger
80
+ .withMetadata({
81
+ path: PROJECTION_STATE_PATH,
82
+ dropped
83
+ })
84
+ .warn("projection state entries dropped");
85
+ }
86
+ return new ProjectionState(source, data);
87
+ }
88
+ static parse(input) {
89
+ if (!kRecordValidator.isValidObject(input)) {
90
+ return {
91
+ data: null,
92
+ dropped: 0
93
+ };
94
+ }
95
+ const checkpoints = pickEntries(input.checkpoints, kCheckpointValidator);
96
+ const failures = pickEntries(input.failures, kFailureValidator);
97
+ return {
98
+ data: {
99
+ version: kStateVersion,
100
+ checkpoints: checkpoints.entries,
101
+ failures: failures.entries
102
+ },
103
+ dropped: checkpoints.dropped + failures.dropped
104
+ };
105
+ }
106
+ }
107
+ function pickEntries(input, validator) {
108
+ if (!kRecordValidator.isValidObject(input)) {
109
+ return {
110
+ entries: {},
111
+ dropped: 0
112
+ };
113
+ }
114
+ const entries = {};
115
+ let dropped = 0;
116
+ for (const [key, value] of Object.entries(input)) {
117
+ const result = validator.validate(value);
118
+ if (result.valid) {
119
+ entries[key] = result.data;
120
+ }
121
+ else {
122
+ dropped += 1;
123
+ }
124
+ }
125
+ return {
126
+ entries,
127
+ dropped
128
+ };
129
+ }
@@ -0,0 +1,36 @@
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 { type Logger } from "../logger.ts";
6
+ import type { AssetProjector } from "./AssetProjector.ts";
7
+ import type { AssetWriter } from "./AssetWriter.ts";
8
+ export interface ReconcileReport {
9
+ readonly created: number;
10
+ readonly updated: number;
11
+ readonly renamed: number;
12
+ readonly deleted: number;
13
+ /**
14
+ * Entries skipped after an append or read failure.
15
+ */
16
+ readonly failed: number;
17
+ }
18
+ export interface ReconcilerOptions {
19
+ source: AssetSource;
20
+ projector: AssetProjector;
21
+ writer: AssetWriter;
22
+ kinds: AssetKindRegistry;
23
+ actor?: EventStore.Actor;
24
+ logger?: Logger;
25
+ }
26
+ /**
27
+ * Reconciles physical files against the last projected event state.
28
+ *
29
+ * Hash equality suppresses echoed writes and makes notifications idempotent.
30
+ */
31
+ export declare class Reconciler {
32
+ #private;
33
+ constructor(options: ReconcilerOptions);
34
+ reconcile(): Promise<Result<ReconcileReport, Error>>;
35
+ }
36
+ //# sourceMappingURL=Reconciler.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Reconciler.d.ts","sourceRoot":"","sources":["../../src/sync/Reconciler.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,KAAK,UAAU,MAAM,0BAA0B,CAAC;AAC5D,OAAO,EAGL,KAAK,MAAM,EACZ,MAAM,kBAAkB,CAAC;AAC1B,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAC;AAG7D,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,+BAA+B,CAAC;AAQvE,OAAO,EAEL,KAAK,MAAM,EACZ,MAAM,cAAc,CAAC;AACtB,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAC1D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AASpD,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB;;OAEG;IACH,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AA+BD,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,WAAW,CAAC;IACpB,SAAS,EAAE,cAAc,CAAC;IAC1B,MAAM,EAAE,WAAW,CAAC;IACpB,KAAK,EAAE,iBAAiB,CAAC;IACzB,KAAK,CAAC,EAAE,UAAU,CAAC,KAAK,CAAC;IACzB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;GAIG;AACH,qBAAa,UAAU;;gBASnB,OAAO,EAAE,iBAAiB;IAUtB,SAAS,IAAI,OAAO,CAAC,MAAM,CAAC,eAAe,EAAE,KAAK,CAAC,CAAC;CA4K3D"}
@@ -0,0 +1,182 @@
1
+ // Import Node.js Dependencies
2
+ import { randomUUID } from "node:crypto";
3
+ import { Err, Ok } from "@openally/result";
4
+ import { contentHash } from "../utils/contentHash.js";
5
+ import { matchRenames } from "./matchRenames.js";
6
+ import { silentLogger } from "../logger.js";
7
+ // CONSTANTS
8
+ const kWatcherActor = {
9
+ type: "system",
10
+ source: "fs-watcher"
11
+ };
12
+ const kReadConcurrency = 16;
13
+ /**
14
+ * Reconciles physical files against the last projected event state.
15
+ *
16
+ * Hash equality suppresses echoed writes and makes notifications idempotent.
17
+ */
18
+ export class Reconciler {
19
+ #source;
20
+ #projector;
21
+ #writer;
22
+ #kinds;
23
+ #actor;
24
+ #logger;
25
+ constructor(options) {
26
+ this.#source = options.source;
27
+ this.#projector = options.projector;
28
+ this.#writer = options.writer;
29
+ this.#kinds = options.kinds;
30
+ this.#actor = options.actor ?? kWatcherActor;
31
+ this.#logger = options.logger ?? silentLogger();
32
+ }
33
+ async reconcile() {
34
+ let scan;
35
+ try {
36
+ scan = await this.#observe();
37
+ }
38
+ catch (error) {
39
+ return Err(asError(error));
40
+ }
41
+ const projected = [];
42
+ for (const { assetId, projection } of this.#projector.projections()) {
43
+ if (scan.unreadable.has(projection.path)) {
44
+ continue;
45
+ }
46
+ projected.push({
47
+ id: assetId,
48
+ path: projection.path,
49
+ kind: projection.kind,
50
+ hash: projection.hash
51
+ });
52
+ }
53
+ const diff = matchRenames(projected, scan.entries);
54
+ const report = {
55
+ created: 0,
56
+ updated: 0,
57
+ renamed: 0,
58
+ deleted: 0,
59
+ failed: scan.unreadable.size
60
+ };
61
+ for (const operation of this.#plan(diff)) {
62
+ await this.#apply(operation, report);
63
+ }
64
+ if (this.#total(report) > 0) {
65
+ this.#logger
66
+ .withMetadata({ ...report })
67
+ .info("reconciled external changes");
68
+ }
69
+ return Ok(report);
70
+ }
71
+ *#plan(diff) {
72
+ for (const entry of diff.deleted) {
73
+ yield {
74
+ outcome: "deleted",
75
+ path: entry.path,
76
+ run: () => this.#writer.remove({
77
+ assetId: entry.id,
78
+ actor: this.#actor,
79
+ alreadyProjected: true
80
+ })
81
+ };
82
+ }
83
+ for (const entry of diff.renamed) {
84
+ yield {
85
+ outcome: "renamed",
86
+ path: entry.to,
87
+ run: () => this.#writer.rename({
88
+ assetId: entry.id,
89
+ to: entry.to,
90
+ actor: this.#actor,
91
+ alreadyProjected: true
92
+ })
93
+ };
94
+ }
95
+ for (const entry of diff.updated) {
96
+ yield {
97
+ outcome: "updated",
98
+ path: entry.path,
99
+ run: async () => this.#writer.update({
100
+ assetId: entry.id,
101
+ data: await this.#source.read(entry.path),
102
+ actor: this.#actor,
103
+ alreadyProjected: true
104
+ })
105
+ };
106
+ }
107
+ for (const entry of diff.created) {
108
+ yield {
109
+ outcome: "created",
110
+ path: entry.path,
111
+ run: async () => this.#writer.create({
112
+ path: entry.path,
113
+ data: await this.#source.read(entry.path),
114
+ kind: this.#kinds.resolve(entry.path).kind,
115
+ assetId: this.#writer.identity.byPath(entry.path)?.id ?? randomUUID(),
116
+ actor: this.#actor,
117
+ alreadyProjected: true
118
+ })
119
+ };
120
+ }
121
+ }
122
+ async #apply(operation, report) {
123
+ const result = await operation.run()
124
+ .catch((error) => Err(asError(error)));
125
+ if (result.ok) {
126
+ report[operation.outcome] += 1;
127
+ return;
128
+ }
129
+ report.failed += 1;
130
+ this.#logger
131
+ .withMetadata({
132
+ path: operation.path,
133
+ outcome: operation.outcome,
134
+ reason: result.val.message
135
+ })
136
+ .error("asset reconciliation failed");
137
+ }
138
+ #total(report) {
139
+ return report.created +
140
+ report.updated +
141
+ report.renamed +
142
+ report.deleted +
143
+ report.failed;
144
+ }
145
+ async #observe() {
146
+ const paths = await this.#source.list();
147
+ const entries = [];
148
+ const unreadable = new Set();
149
+ for (let index = 0; index < paths.length; index += kReadConcurrency) {
150
+ const batch = paths.slice(index, index + kReadConcurrency);
151
+ const results = await Promise.all(batch.map(async (path) => {
152
+ try {
153
+ return {
154
+ path,
155
+ entry: {
156
+ path,
157
+ hash: contentHash(await this.#source.read(path))
158
+ }
159
+ };
160
+ }
161
+ catch (error) {
162
+ this.#logger
163
+ .withMetadata({ path, reason: asError(error).message })
164
+ .warn("asset not readable, left untouched");
165
+ return { path, entry: null };
166
+ }
167
+ }));
168
+ for (const { path, entry } of results) {
169
+ if (entry === null) {
170
+ unreadable.add(path);
171
+ }
172
+ else {
173
+ entries.push(entry);
174
+ }
175
+ }
176
+ }
177
+ return { entries, unreadable };
178
+ }
179
+ }
180
+ function asError(error) {
181
+ return error instanceof Error ? error : new Error(String(error));
182
+ }
@@ -0,0 +1,28 @@
1
+ import type { AssetSource } from "@jolly-pixel/asset-source";
2
+ import { type Logger } from "../logger.ts";
3
+ import type { Reconciler } from "./Reconciler.ts";
4
+ export interface ReconciliationWatcherOptions {
5
+ source: AssetSource;
6
+ reconciler: Reconciler;
7
+ /**
8
+ * Quiet period, in milliseconds, before a batch of notifications turns
9
+ * into one reconciliation pass.
10
+ * @default 200
11
+ */
12
+ debounce?: number;
13
+ logger?: Logger;
14
+ }
15
+ /**
16
+ * Coalesces filesystem notifications into idempotent reconciliation passes.
17
+ */
18
+ export declare class ReconciliationWatcher {
19
+ #private;
20
+ constructor(options: ReconciliationWatcherOptions);
21
+ get watching(): boolean;
22
+ start(): void;
23
+ notify(path: string): void;
24
+ run(): Promise<void>;
25
+ settle(): Promise<void>;
26
+ close(): Promise<void>;
27
+ }
28
+ //# sourceMappingURL=ReconciliationWatcher.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ReconciliationWatcher.d.ts","sourceRoot":"","sources":["../../src/sync/ReconciliationWatcher.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAC;AAG7D,OAAO,EAEL,KAAK,MAAM,EACZ,MAAM,cAAc,CAAC;AACtB,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAKlD,MAAM,WAAW,4BAA4B;IAC3C,MAAM,EAAE,WAAW,CAAC;IACpB,UAAU,EAAE,UAAU,CAAC;IACvB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;GAEG;AACH,qBAAa,qBAAqB;;gBAY9B,OAAO,EAAE,4BAA4B;IAQvC,IAAI,QAAQ,IAAI,OAAO,CAEtB;IAED,KAAK,IAAI,IAAI;IAWb,MAAM,CACJ,IAAI,EAAE,MAAM,GACX,IAAI;IAeP,GAAG,IAAI,OAAO,CAAC,IAAI,CAAC;IAkBd,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC;IAIvB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CA6B7B"}
@@ -0,0 +1,86 @@
1
+ // Import Internal Dependencies
2
+ import { silentLogger } from "../logger.js";
3
+ // CONSTANTS
4
+ const kDefaultDebounce = 200;
5
+ /**
6
+ * Coalesces filesystem notifications into idempotent reconciliation passes.
7
+ */
8
+ export class ReconciliationWatcher {
9
+ #source;
10
+ #reconciler;
11
+ #debounce;
12
+ #logger;
13
+ #unwatch = null;
14
+ #handle = null;
15
+ #running = null;
16
+ #again = false;
17
+ constructor(options) {
18
+ this.#source = options.source;
19
+ this.#reconciler = options.reconciler;
20
+ this.#debounce = options.debounce ?? kDefaultDebounce;
21
+ this.#logger = options.logger ?? silentLogger();
22
+ }
23
+ get watching() {
24
+ return this.#unwatch !== null;
25
+ }
26
+ start() {
27
+ if (this.#unwatch !== null ||
28
+ this.#source.watch === undefined) {
29
+ return;
30
+ }
31
+ this.#unwatch = this.#source.watch((path) => this.notify(path));
32
+ }
33
+ notify(path) {
34
+ this.#logger
35
+ .withMetadata({ path })
36
+ .debug("filesystem change observed");
37
+ if (this.#handle !== null) {
38
+ clearTimeout(this.#handle);
39
+ }
40
+ this.#handle = setTimeout(() => void this.run(), this.#debounce);
41
+ this.#handle.unref();
42
+ }
43
+ run() {
44
+ if (this.#handle !== null) {
45
+ clearTimeout(this.#handle);
46
+ this.#handle = null;
47
+ }
48
+ if (this.#running !== null) {
49
+ this.#again = true;
50
+ return this.#running;
51
+ }
52
+ const pass = this.#run();
53
+ this.#running = pass;
54
+ return pass;
55
+ }
56
+ async settle() {
57
+ await this.#running;
58
+ }
59
+ async close() {
60
+ if (this.#unwatch !== null) {
61
+ this.#unwatch();
62
+ this.#unwatch = null;
63
+ }
64
+ if (this.#handle !== null) {
65
+ clearTimeout(this.#handle);
66
+ this.#handle = null;
67
+ }
68
+ await this.#running;
69
+ }
70
+ async #run() {
71
+ try {
72
+ do {
73
+ this.#again = false;
74
+ const result = await this.#reconciler.reconcile();
75
+ if (!result.ok) {
76
+ this.#logger
77
+ .withMetadata({ reason: result.val.message })
78
+ .error("reconciliation failed");
79
+ }
80
+ } while (this.#again);
81
+ }
82
+ finally {
83
+ this.#running = null;
84
+ }
85
+ }
86
+ }
@@ -0,0 +1,29 @@
1
+ import type * as EventStore from "@jolly-pixel/event-store";
2
+ import { type Logger } from "../logger.ts";
3
+ import { type AssetEventDataMap } from "../events/AssetEvents.ts";
4
+ import type { SnapshotPolicy } from "../kinds/AssetKindHandler.ts";
5
+ import type { AssetStateStore } from "./AssetStateStore.ts";
6
+ import type { AssetProjector } from "./AssetProjector.ts";
7
+ export interface SnapshotSchedulerOptions {
8
+ eventStore: EventStore.TypedEventStore<AssetEventDataMap>;
9
+ states: AssetStateStore;
10
+ projector: AssetProjector;
11
+ snapshot?: SnapshotPolicy;
12
+ logger?: Logger;
13
+ }
14
+ /**
15
+ * Schedules snapshots by quiet period and maximum delay per asset.
16
+ *
17
+ * Snapshots append events; the projector performs the physical write.
18
+ */
19
+ export declare class SnapshotScheduler {
20
+ #private;
21
+ constructor(options: SnapshotSchedulerOptions);
22
+ get pending(): number;
23
+ start(): void;
24
+ close(): Promise<void>;
25
+ schedule(assetId: string): void;
26
+ flush(assetId?: string): Promise<void>;
27
+ snapshot(assetId: string): Promise<boolean>;
28
+ }
29
+ //# sourceMappingURL=SnapshotScheduler.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SnapshotScheduler.d.ts","sourceRoot":"","sources":["../../src/sync/SnapshotScheduler.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,KAAK,UAAU,MAAM,0BAA0B,CAAC;AAG5D,OAAO,EAEL,KAAK,MAAM,EACZ,MAAM,cAAc,CAAC;AAEtB,OAAO,EAIL,KAAK,iBAAiB,EAEvB,MAAM,0BAA0B,CAAC;AAClC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AACnE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAC5D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAY1D,MAAM,WAAW,wBAAwB;IACvC,UAAU,EAAE,UAAU,CAAC,eAAe,CAAC,iBAAiB,CAAC,CAAC;IAC1D,MAAM,EAAE,eAAe,CAAC;IACxB,SAAS,EAAE,cAAc,CAAC;IAC1B,QAAQ,CAAC,EAAE,cAAc,CAAC;IAC1B,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;GAIG;AACH,qBAAa,iBAAiB;;gBAY1B,OAAO,EAAE,wBAAwB;IAYnC,IAAI,OAAO,IAAI,MAAM,CAEpB;IAED,KAAK,IAAI,IAAI;IAQP,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAO5B,QAAQ,CACN,OAAO,EAAE,MAAM,GACd,IAAI;IAmCD,KAAK,CACT,OAAO,CAAC,EAAE,MAAM,GACf,OAAO,CAAC,IAAI,CAAC;IAiBhB,QAAQ,CACN,OAAO,EAAE,MAAM,GACd,OAAO,CAAC,OAAO,CAAC;CA+DpB"}
@@ -0,0 +1,136 @@
1
+ // Import Internal Dependencies
2
+ import { silentLogger } from "../logger.js";
3
+ import { contentHash } from "../utils/contentHash.js";
4
+ import { ASSET_UPDATED, encodeContent, isAssetEventType } from "../events/AssetEvents.js";
5
+ import { TaskChain } from "../utils/TaskChain.js";
6
+ // CONSTANTS
7
+ const kDefaultDelay = 2_000;
8
+ const kDefaultMaxDelay = 30_000;
9
+ /**
10
+ * Schedules snapshots by quiet period and maximum delay per asset.
11
+ *
12
+ * Snapshots append events; the projector performs the physical write.
13
+ */
14
+ export class SnapshotScheduler {
15
+ #eventStore;
16
+ #states;
17
+ #projector;
18
+ #policy;
19
+ #logger;
20
+ #pending = new Map();
21
+ #chains = new Map();
22
+ #unsubscribe = null;
23
+ constructor(options) {
24
+ this.#eventStore = options.eventStore;
25
+ this.#states = options.states;
26
+ this.#projector = options.projector;
27
+ this.#policy = {
28
+ delay: options.snapshot?.delay ?? kDefaultDelay,
29
+ maxDelay: options.snapshot?.maxDelay ?? kDefaultMaxDelay
30
+ };
31
+ this.#logger = options.logger ?? silentLogger();
32
+ }
33
+ get pending() {
34
+ return this.#pending.size;
35
+ }
36
+ start() {
37
+ this.#unsubscribe ??= this.#eventStore.subscribe((event) => {
38
+ if (!isAssetEventType(event.eventType)) {
39
+ this.schedule(event.assetId);
40
+ }
41
+ });
42
+ }
43
+ async close() {
44
+ this.#unsubscribe?.();
45
+ this.#unsubscribe = null;
46
+ await this.flush();
47
+ }
48
+ schedule(assetId) {
49
+ const entry = this.#states.get(assetId);
50
+ if (entry === undefined) {
51
+ return;
52
+ }
53
+ const policy = {
54
+ delay: entry.handler.snapshot?.delay ?? this.#policy.delay,
55
+ maxDelay: entry.handler.snapshot?.maxDelay ?? this.#policy.maxDelay
56
+ };
57
+ const pending = this.#pending.get(assetId);
58
+ const firstEventAt = pending?.firstEventAt ?? Date.now();
59
+ if (pending !== undefined) {
60
+ clearTimeout(pending.handle);
61
+ }
62
+ const elapsed = Date.now() - firstEventAt;
63
+ const delay = Math.max(0, Math.min(policy.delay, policy.maxDelay - elapsed));
64
+ const handle = setTimeout(() => void this.snapshot(assetId), delay);
65
+ handle.unref();
66
+ this.#pending.set(assetId, {
67
+ firstEventAt,
68
+ handle
69
+ });
70
+ }
71
+ async flush(assetId) {
72
+ const targets = assetId === undefined ?
73
+ [...this.#pending.keys()] :
74
+ [assetId].filter((id) => this.#pending.has(id));
75
+ for (const target of targets) {
76
+ await this.snapshot(target);
77
+ }
78
+ const chains = assetId === undefined ?
79
+ [...this.#chains.values()] :
80
+ [this.#chains.get(assetId)];
81
+ await Promise.all(chains.map((chain) => chain?.settled()));
82
+ }
83
+ snapshot(assetId) {
84
+ let chain = this.#chains.get(assetId);
85
+ if (chain === undefined) {
86
+ chain = new TaskChain();
87
+ this.#chains.set(assetId, chain);
88
+ }
89
+ return chain.run(() => this.#snapshot(assetId));
90
+ }
91
+ async #snapshot(assetId) {
92
+ const pending = this.#pending.get(assetId);
93
+ if (pending !== undefined) {
94
+ clearTimeout(pending.handle);
95
+ this.#pending.delete(assetId);
96
+ }
97
+ const entry = this.#states.get(assetId);
98
+ const desired = this.#projector.desired(assetId);
99
+ if (entry === undefined || desired === null) {
100
+ return false;
101
+ }
102
+ const data = await entry.handler.serialize(entry.state);
103
+ const hash = contentHash(data);
104
+ if (hash === desired.hash) {
105
+ return false;
106
+ }
107
+ const eventData = {
108
+ path: desired.path,
109
+ kind: desired.kind,
110
+ hash,
111
+ size: data.byteLength,
112
+ content: encodeContent(data)
113
+ };
114
+ const appended = this.#eventStore.writer.append({
115
+ assetType: entry.kind,
116
+ assetId,
117
+ eventType: ASSET_UPDATED,
118
+ eventData,
119
+ actor: {
120
+ type: "system",
121
+ source: "snapshot"
122
+ }
123
+ });
124
+ if (!appended.ok) {
125
+ this.#logger
126
+ .withMetadata({
127
+ assetId,
128
+ reason: appended.val.message
129
+ })
130
+ .error("asset snapshot failed");
131
+ return false;
132
+ }
133
+ await this.#projector.flush(assetId);
134
+ return true;
135
+ }
136
+ }