@ifc-lite/collab-server 0.2.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 +373 -0
- package/README.md +47 -0
- package/dist/audit-log.d.ts +77 -0
- package/dist/audit-log.d.ts.map +1 -0
- package/dist/audit-log.js +105 -0
- package/dist/audit-log.js.map +1 -0
- package/dist/auth.d.ts +24 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +15 -0
- package/dist/auth.js.map +1 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +34 -0
- package/dist/bin.js.map +1 -0
- package/dist/blob-route.d.ts +58 -0
- package/dist/blob-route.d.ts.map +1 -0
- package/dist/blob-route.js +132 -0
- package/dist/blob-route.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/metrics.d.ts +56 -0
- package/dist/metrics.d.ts.map +1 -0
- package/dist/metrics.js +161 -0
- package/dist/metrics.js.map +1 -0
- package/dist/path-locks.d.ts +39 -0
- package/dist/path-locks.d.ts.map +1 -0
- package/dist/path-locks.js +176 -0
- package/dist/path-locks.js.map +1 -0
- package/dist/persistence-redis.d.ts +52 -0
- package/dist/persistence-redis.d.ts.map +1 -0
- package/dist/persistence-redis.js +50 -0
- package/dist/persistence-redis.js.map +1 -0
- package/dist/persistence-s3.d.ts +81 -0
- package/dist/persistence-s3.d.ts.map +1 -0
- package/dist/persistence-s3.js +183 -0
- package/dist/persistence-s3.js.map +1 -0
- package/dist/persistence.d.ts +41 -0
- package/dist/persistence.d.ts.map +1 -0
- package/dist/persistence.js +108 -0
- package/dist/persistence.js.map +1 -0
- package/dist/rate-limit.d.ts +29 -0
- package/dist/rate-limit.d.ts.map +1 -0
- package/dist/rate-limit.js +36 -0
- package/dist/rate-limit.js.map +1 -0
- package/dist/replay-protect.d.ts +51 -0
- package/dist/replay-protect.d.ts.map +1 -0
- package/dist/replay-protect.js +134 -0
- package/dist/replay-protect.js.map +1 -0
- package/dist/retention.d.ts +32 -0
- package/dist/retention.d.ts.map +1 -0
- package/dist/retention.js +112 -0
- package/dist/retention.js.map +1 -0
- package/dist/room-manager.d.ts +138 -0
- package/dist/room-manager.d.ts.map +1 -0
- package/dist/room-manager.js +380 -0
- package/dist/room-manager.js.map +1 -0
- package/dist/secure-bundle.d.ts +15 -0
- package/dist/secure-bundle.d.ts.map +1 -0
- package/dist/secure-bundle.js +49 -0
- package/dist/secure-bundle.js.map +1 -0
- package/dist/secure-server.d.ts +49 -0
- package/dist/secure-server.d.ts.map +1 -0
- package/dist/secure-server.js +86 -0
- package/dist/secure-server.js.map +1 -0
- package/dist/server.d.ts +59 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +196 -0
- package/dist/server.js.map +1 -0
- package/dist/snapshot-worker.d.ts +32 -0
- package/dist/snapshot-worker.d.ts.map +1 -0
- package/dist/snapshot-worker.js +87 -0
- package/dist/snapshot-worker.js.map +1 -0
- package/package.json +66 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
|
+
/**
|
|
5
|
+
* Per-section / per-path write locks (spec §8.2 — open problem #7).
|
|
6
|
+
*
|
|
7
|
+
* Granular locks beyond role-based gating: an admin can declare that
|
|
8
|
+
* specific entity-path prefixes (or relationship/geometry paths) are
|
|
9
|
+
* temporarily read-only — e.g. "MEP coordination is in review, no
|
|
10
|
+
* structural changes." The server inspects each incoming Y update,
|
|
11
|
+
* decodes the affected paths, and rejects writes that touch any
|
|
12
|
+
* locked prefix.
|
|
13
|
+
*
|
|
14
|
+
* The lock policy is path-prefix matching: a lock on
|
|
15
|
+
* `proj/arch/storey-1/` blocks writes to any entity whose path starts
|
|
16
|
+
* with that prefix. Locks may also exempt specific principals (e.g.
|
|
17
|
+
* the locking admin can still edit) via `exemptUserIds` /
|
|
18
|
+
* `exemptRoles`.
|
|
19
|
+
*
|
|
20
|
+
* Implementation note: we don't decode the entire Y update wire
|
|
21
|
+
* format here — instead, we run the update through a throwaway Y.Doc
|
|
22
|
+
* to reuse Yjs's parser, then walk the resulting tr.changed map to
|
|
23
|
+
* harvest paths. That's O(update_size) but only runs on writes from
|
|
24
|
+
* non-exempt principals.
|
|
25
|
+
*/
|
|
26
|
+
import * as Y from 'yjs';
|
|
27
|
+
import * as decoding from 'lib0/decoding';
|
|
28
|
+
import * as syncProtocol from 'y-protocols/sync';
|
|
29
|
+
export function createPathLockRegistry() {
|
|
30
|
+
const locks = [];
|
|
31
|
+
return {
|
|
32
|
+
list: () => [...locks],
|
|
33
|
+
add(lock) {
|
|
34
|
+
locks.push(lock);
|
|
35
|
+
return lock;
|
|
36
|
+
},
|
|
37
|
+
remove(lock) {
|
|
38
|
+
const idx = locks.indexOf(lock);
|
|
39
|
+
if (idx < 0)
|
|
40
|
+
return false;
|
|
41
|
+
locks.splice(idx, 1);
|
|
42
|
+
return true;
|
|
43
|
+
},
|
|
44
|
+
clear() {
|
|
45
|
+
locks.length = 0;
|
|
46
|
+
},
|
|
47
|
+
matches(path, principal) {
|
|
48
|
+
for (const lock of locks) {
|
|
49
|
+
if (!path.startsWith(lock.prefix))
|
|
50
|
+
continue;
|
|
51
|
+
if (lock.exemptUserIds?.has(principal.userId))
|
|
52
|
+
continue;
|
|
53
|
+
if (lock.exemptRoles?.has(principal.role))
|
|
54
|
+
continue;
|
|
55
|
+
return lock;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Apply `update` to a throwaway Y.Doc and harvest the touched paths
|
|
63
|
+
* as a list of `<top>/<key>` strings (`entities/wall`, `geometry/g1`,
|
|
64
|
+
* etc.). Each top-level shared type the runtime knows about is
|
|
65
|
+
* inspected.
|
|
66
|
+
*/
|
|
67
|
+
export function harvestUpdatePaths(update) {
|
|
68
|
+
const tmp = new Y.Doc();
|
|
69
|
+
tmp.getMap('entities');
|
|
70
|
+
tmp.getMap('relationships');
|
|
71
|
+
tmp.getMap('geometry');
|
|
72
|
+
tmp.getMap('meta');
|
|
73
|
+
const touched = new Set();
|
|
74
|
+
const onAfter = (tr) => {
|
|
75
|
+
for (const [type, keys] of tr.changed.entries()) {
|
|
76
|
+
let node = type;
|
|
77
|
+
const basePath = [];
|
|
78
|
+
while (node) {
|
|
79
|
+
const item = node._item;
|
|
80
|
+
if (!item) {
|
|
81
|
+
// Top-level: find its name.
|
|
82
|
+
const name = topLevelKeyOf(node, tmp);
|
|
83
|
+
if (name)
|
|
84
|
+
basePath.unshift(name);
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
if (typeof item.parentSub === 'string')
|
|
88
|
+
basePath.unshift(item.parentSub);
|
|
89
|
+
node = item.parent;
|
|
90
|
+
}
|
|
91
|
+
if (basePath.length === 0)
|
|
92
|
+
continue;
|
|
93
|
+
// Always emit the base path itself …
|
|
94
|
+
touched.add(basePath.join('/'));
|
|
95
|
+
// … and every changed key under it (key === null means the
|
|
96
|
+
// change is on a Y.Array's internal struct list — only the
|
|
97
|
+
// base path is meaningful).
|
|
98
|
+
for (const key of keys) {
|
|
99
|
+
if (key === null)
|
|
100
|
+
continue;
|
|
101
|
+
touched.add([...basePath, key].join('/'));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
tmp.on('afterTransaction', onAfter);
|
|
106
|
+
Y.applyUpdate(tmp, update);
|
|
107
|
+
tmp.off('afterTransaction', onAfter);
|
|
108
|
+
tmp.destroy();
|
|
109
|
+
return [...touched];
|
|
110
|
+
}
|
|
111
|
+
function topLevelKeyOf(type, doc) {
|
|
112
|
+
for (const [name, shared] of doc.share) {
|
|
113
|
+
if (shared === type)
|
|
114
|
+
return name;
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Build a `VerifyMessageFn` that rejects sync-update messages whose
|
|
120
|
+
* harvested paths intersect a locked prefix (and whose principal
|
|
121
|
+
* isn't exempt). Pass-through for non-update frames.
|
|
122
|
+
*/
|
|
123
|
+
export function verifyAgainstPathLocks(registry) {
|
|
124
|
+
return (msg, conn) => {
|
|
125
|
+
if (msg.byteLength === 0)
|
|
126
|
+
return { ok: true };
|
|
127
|
+
// Outer envelope is the y-protocols sync frame: [varint type][...].
|
|
128
|
+
const decoder = decoding.createDecoder(msg);
|
|
129
|
+
let outerType;
|
|
130
|
+
try {
|
|
131
|
+
outerType = decoding.readVarUint(decoder);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// Malformed envelope: pass through. The frame can't apply anything
|
|
135
|
+
// to the doc, so room-manager's own readSyncMessage will swallow
|
|
136
|
+
// it without side effects.
|
|
137
|
+
return { ok: true };
|
|
138
|
+
}
|
|
139
|
+
// Outer 0 = sync; inner 0/1/2 = step1/step2/update.
|
|
140
|
+
if (outerType !== 0)
|
|
141
|
+
return { ok: true };
|
|
142
|
+
let inner;
|
|
143
|
+
try {
|
|
144
|
+
inner = decoding.readVarUint(decoder);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return { ok: true };
|
|
148
|
+
}
|
|
149
|
+
// Both messageYjsSyncStep2 and messageYjsUpdate carry a Yjs binary
|
|
150
|
+
// update payload that readSyncMessage applies to the doc as a side
|
|
151
|
+
// effect — they must both go through path-lock checking. Step1 is
|
|
152
|
+
// pure read intent and is exempt.
|
|
153
|
+
const isWriteFrame = inner === syncProtocol.messageYjsUpdate ||
|
|
154
|
+
inner === syncProtocol.messageYjsSyncStep2;
|
|
155
|
+
if (!isWriteFrame)
|
|
156
|
+
return { ok: true };
|
|
157
|
+
let payload;
|
|
158
|
+
try {
|
|
159
|
+
payload = decoding.readVarUint8Array(decoder);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
// A write-frame with an unreadable payload is suspicious — fail
|
|
163
|
+
// closed rather than waving the message through to the doc.
|
|
164
|
+
return { ok: false, reason: 'malformed-update-payload' };
|
|
165
|
+
}
|
|
166
|
+
const paths = harvestUpdatePaths(payload);
|
|
167
|
+
for (const p of paths) {
|
|
168
|
+
const hit = registry.matches(p, conn.principal);
|
|
169
|
+
if (hit) {
|
|
170
|
+
return { ok: false, reason: `locked:${hit.label ?? hit.prefix}` };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return { ok: true };
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
//# sourceMappingURL=path-locks.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"path-locks.js","sourceRoot":"","sources":["../src/path-locks.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redis persistence backend (spec §12.2).
|
|
3
|
+
*
|
|
4
|
+
* Same trick as `S3Persistence`: implemented against a tiny
|
|
5
|
+
* `RedisLikeClient` interface so deployers wire up `ioredis`,
|
|
6
|
+
* `node-redis`, or any compatible client without forcing
|
|
7
|
+
* `@ifc-lite/collab-server` to take a hard dep.
|
|
8
|
+
*
|
|
9
|
+
* Layout per room:
|
|
10
|
+
* - key `<prefix><roomId>:snap` ← compacted state (Buffer)
|
|
11
|
+
* - list `<prefix><roomId>:log` ← rolling log frames (Buffer items)
|
|
12
|
+
*
|
|
13
|
+
* `load(roomId)` returns `concat(snap, ...all log items)`. `compact`
|
|
14
|
+
* sets `:snap` and trims `:log` to empty. `drop` deletes both keys.
|
|
15
|
+
*/
|
|
16
|
+
import type { Persistence } from './persistence.js';
|
|
17
|
+
/**
|
|
18
|
+
* Minimal Redis-like surface. The two big Node clients (ioredis,
|
|
19
|
+
* node-redis 4+) both satisfy this when wrapped trivially. Buffers
|
|
20
|
+
* are returned for binary fidelity.
|
|
21
|
+
*/
|
|
22
|
+
export interface RedisLikeClient {
|
|
23
|
+
/** Get a binary string value, or `null` if missing. */
|
|
24
|
+
getBuffer(key: string): Promise<Buffer | null>;
|
|
25
|
+
/** Set a binary string value (no expiry by default). */
|
|
26
|
+
set(key: string, value: Buffer): Promise<unknown>;
|
|
27
|
+
/** RPUSH a buffer onto a list. */
|
|
28
|
+
rpush(key: string, value: Buffer): Promise<unknown>;
|
|
29
|
+
/** LRANGE 0 -1 returning all binary items in order. */
|
|
30
|
+
lrangeBuffer(key: string): Promise<Buffer[]>;
|
|
31
|
+
/** DEL one or more keys. */
|
|
32
|
+
del(...keys: string[]): Promise<unknown>;
|
|
33
|
+
/** LTRIM <key> <start> <stop>. */
|
|
34
|
+
ltrim(key: string, start: number, stop: number): Promise<unknown>;
|
|
35
|
+
}
|
|
36
|
+
export interface RedisPersistenceOptions {
|
|
37
|
+
client: RedisLikeClient;
|
|
38
|
+
/** Optional key prefix, e.g. `'collab:'`. Default `''`. */
|
|
39
|
+
prefix?: string;
|
|
40
|
+
}
|
|
41
|
+
export declare class RedisPersistence implements Persistence {
|
|
42
|
+
private readonly client;
|
|
43
|
+
private readonly prefix;
|
|
44
|
+
constructor(opts: RedisPersistenceOptions);
|
|
45
|
+
private snapKey;
|
|
46
|
+
private logKey;
|
|
47
|
+
load(roomId: string): Promise<Uint8Array | null>;
|
|
48
|
+
append(roomId: string, update: Uint8Array): Promise<void>;
|
|
49
|
+
compact(roomId: string, mergedState: Uint8Array): Promise<void>;
|
|
50
|
+
drop(roomId: string): Promise<void>;
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=persistence-redis.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"persistence-redis.d.ts","sourceRoot":"","sources":["../src/persistence-redis.ts"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAEpD;;;;GAIG;AACH,MAAM,WAAW,eAAe;IAC9B,uDAAuD;IACvD,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC/C,wDAAwD;IACxD,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAClD,kCAAkC;IAClC,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACpD,uDAAuD;IACvD,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAC7C,4BAA4B;IAC5B,GAAG,CAAC,GAAG,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACzC,kCAAkC;IAClC,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACnE;AAED,MAAM,WAAW,uBAAuB;IACtC,MAAM,EAAE,eAAe,CAAC;IACxB,2DAA2D;IAC3D,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,qBAAa,gBAAiB,YAAW,WAAW;IAClD,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAkB;IACzC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;gBAEpB,IAAI,EAAE,uBAAuB;IAKzC,OAAO,CAAC,OAAO;IAGf,OAAO,CAAC,MAAM;IAIR,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;IAmBhD,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC;IAIzD,OAAO,CAAC,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC;IAM/D,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAG1C"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
|
+
export class RedisPersistence {
|
|
5
|
+
client;
|
|
6
|
+
prefix;
|
|
7
|
+
constructor(opts) {
|
|
8
|
+
this.client = opts.client;
|
|
9
|
+
this.prefix = opts.prefix ?? '';
|
|
10
|
+
}
|
|
11
|
+
snapKey(roomId) {
|
|
12
|
+
return `${this.prefix}${roomId}:snap`;
|
|
13
|
+
}
|
|
14
|
+
logKey(roomId) {
|
|
15
|
+
return `${this.prefix}${roomId}:log`;
|
|
16
|
+
}
|
|
17
|
+
async load(roomId) {
|
|
18
|
+
const [snap, frames] = await Promise.all([
|
|
19
|
+
this.client.getBuffer(this.snapKey(roomId)),
|
|
20
|
+
this.client.lrangeBuffer(this.logKey(roomId)),
|
|
21
|
+
]);
|
|
22
|
+
const parts = [];
|
|
23
|
+
if (snap && snap.byteLength > 0)
|
|
24
|
+
parts.push(snap);
|
|
25
|
+
for (const f of frames ?? [])
|
|
26
|
+
parts.push(f);
|
|
27
|
+
if (parts.length === 0)
|
|
28
|
+
return null;
|
|
29
|
+
const total = parts.reduce((n, p) => n + p.byteLength, 0);
|
|
30
|
+
const out = new Uint8Array(total);
|
|
31
|
+
let o = 0;
|
|
32
|
+
for (const p of parts) {
|
|
33
|
+
out.set(new Uint8Array(p.buffer, p.byteOffset, p.byteLength), o);
|
|
34
|
+
o += p.byteLength;
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
async append(roomId, update) {
|
|
39
|
+
await this.client.rpush(this.logKey(roomId), Buffer.from(update));
|
|
40
|
+
}
|
|
41
|
+
async compact(roomId, mergedState) {
|
|
42
|
+
await this.client.set(this.snapKey(roomId), Buffer.from(mergedState));
|
|
43
|
+
// Truncate the log to empty.
|
|
44
|
+
await this.client.ltrim(this.logKey(roomId), 1, 0);
|
|
45
|
+
}
|
|
46
|
+
async drop(roomId) {
|
|
47
|
+
await this.client.del(this.snapKey(roomId), this.logKey(roomId));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=persistence-redis.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"persistence-redis.js","sourceRoot":"","sources":["../src/persistence-redis.ts"],"names":[],"mappings":"AAAA;;+DAE+D;AA8C/D,MAAM,OAAO,gBAAgB;IACV,MAAM,CAAkB;IACxB,MAAM,CAAS;IAEhC,YAAY,IAA6B;QACvC,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC;QAC1B,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC,MAAM,IAAI,EAAE,CAAC;IAClC,CAAC;IAEO,OAAO,CAAC,MAAc;QAC5B,OAAO,GAAG,IAAI,CAAC,MAAM,GAAG,MAAM,OAAO,CAAC;IACxC,CAAC;IACO,MAAM,CAAC,MAAc;QAC3B,OAAO,GAAG,IAAI,CAAC,MAAM,GAAG,MAAM,MAAM,CAAC;IACvC,CAAC;IAED,KAAK,CAAC,IAAI,CAAC,MAAc;QACvB,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,GAAG,MAAM,OAAO,CAAC,GAAG,CAAC;YACvC,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;YAC3C,IAAI,CAAC,MAAM,CAAC,YAAY,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;SAC9C,CAAC,CAAC;QACH,MAAM,KAAK,GAAa,EAAE,CAAC;QAC3B,IAAI,IAAI,IAAI,IAAI,CAAC,UAAU,GAAG,CAAC;YAAE,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAClD,KAAK,MAAM,CAAC,IAAI,MAAM,IAAI,EAAE;YAAE,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAC5C,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,IAAI,CAAC;QACpC,MAAM,KAAK,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,UAAU,EAAE,CAAC,CAAC,CAAC;QAC1D,MAAM,GAAG,GAAG,IAAI,UAAU,CAAC,KAAK,CAAC,CAAC;QAClC,IAAI,CAAC,GAAG,CAAC,CAAC;QACV,KAAK,MAAM,CAAC,IAAI,KAAK,EAAE,CAAC;YACtB,GAAG,CAAC,GAAG,CAAC,IAAI,UAAU,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC,UAAU,EAAE,CAAC,CAAC,UAAU,CAAC,EAAE,CAAC,CAAC,CAAC;YACjE,CAAC,IAAI,CAAC,CAAC,UAAU,CAAC;QACpB,CAAC;QACD,OAAO,GAAG,CAAC;IACb,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,MAAc,EAAE,MAAkB;QAC7C,MAAM,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC;IACpE,CAAC;IAED,KAAK,CAAC,OAAO,CAAC,MAAc,EAAE,WAAuB;QACnD,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC;QACtE,6BAA6B;QAC7B,MAAM,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC;IACrD,CAAC;IAED,KAAK,CAAC,IAAI,CAAC,MAAc;QACvB,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC;IACnE,CAAC;CACF"}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { Persistence } from './persistence.js';
|
|
2
|
+
/** Minimum S3 surface we need; AWS SDK satisfies it directly. */
|
|
3
|
+
export interface S3LikeClient {
|
|
4
|
+
send(command: unknown): Promise<unknown>;
|
|
5
|
+
}
|
|
6
|
+
export interface S3Commands {
|
|
7
|
+
PutObjectCommand: new (input: PutObjectInput) => unknown;
|
|
8
|
+
GetObjectCommand: new (input: GetObjectInput) => unknown;
|
|
9
|
+
DeleteObjectCommand: new (input: {
|
|
10
|
+
Bucket: string;
|
|
11
|
+
Key: string;
|
|
12
|
+
}) => unknown;
|
|
13
|
+
ListObjectsV2Command: new (input: {
|
|
14
|
+
Bucket: string;
|
|
15
|
+
Prefix?: string;
|
|
16
|
+
}) => unknown;
|
|
17
|
+
HeadObjectCommand?: new (input: {
|
|
18
|
+
Bucket: string;
|
|
19
|
+
Key: string;
|
|
20
|
+
}) => unknown;
|
|
21
|
+
}
|
|
22
|
+
export interface PutObjectInput {
|
|
23
|
+
Bucket: string;
|
|
24
|
+
Key: string;
|
|
25
|
+
Body: Uint8Array | Buffer;
|
|
26
|
+
ContentType?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface GetObjectInput {
|
|
29
|
+
Bucket: string;
|
|
30
|
+
Key: string;
|
|
31
|
+
}
|
|
32
|
+
export interface S3PersistenceOptions {
|
|
33
|
+
client: S3LikeClient;
|
|
34
|
+
commands: S3Commands;
|
|
35
|
+
bucket: string;
|
|
36
|
+
/** Optional key prefix, e.g. `'collab/'`. Default `''`. */
|
|
37
|
+
prefix?: string;
|
|
38
|
+
/** Frame size cap in bytes per log entry. Default 1 MB. */
|
|
39
|
+
frameMaxBytes?: number;
|
|
40
|
+
}
|
|
41
|
+
export declare class S3Persistence implements Persistence {
|
|
42
|
+
private readonly client;
|
|
43
|
+
private readonly cmds;
|
|
44
|
+
private readonly bucket;
|
|
45
|
+
private readonly prefix;
|
|
46
|
+
private readonly frameMaxBytes;
|
|
47
|
+
/**
|
|
48
|
+
* In-process monotonic counter — guarantees same-millisecond appends
|
|
49
|
+
* sort in submission order. Combined with `randomBytes` it stays
|
|
50
|
+
* globally unique across processes.
|
|
51
|
+
*/
|
|
52
|
+
private appendCounter;
|
|
53
|
+
constructor(opts: S3PersistenceOptions);
|
|
54
|
+
/**
|
|
55
|
+
* Map a room id to a safe, *unique* key segment. `encodeURIComponent`
|
|
56
|
+
* preserves distinct ids (so `a/b` and `a_b` never collide) while keeping
|
|
57
|
+
* the result safe for S3 keys.
|
|
58
|
+
*/
|
|
59
|
+
private safeRoom;
|
|
60
|
+
private snapKey;
|
|
61
|
+
/**
|
|
62
|
+
* Per-frame key composed of:
|
|
63
|
+
* - a fixed-width unix-ms timestamp (sorts by wall-clock order),
|
|
64
|
+
* - an in-process monotonic counter (breaks ties when two appends
|
|
65
|
+
* land in the same millisecond — without this, two same-ms
|
|
66
|
+
* appends from the same writer could replay out of order),
|
|
67
|
+
* - a random suffix (gives cross-process uniqueness when multiple
|
|
68
|
+
* server instances serve the same room).
|
|
69
|
+
*
|
|
70
|
+
* Lexicographic key ordering = chronological-then-submission ordering,
|
|
71
|
+
* which is what `load()` relies on.
|
|
72
|
+
*/
|
|
73
|
+
private logKey;
|
|
74
|
+
private getObjectBytes;
|
|
75
|
+
load(roomId: string): Promise<Uint8Array | null>;
|
|
76
|
+
append(roomId: string, update: Uint8Array): Promise<void>;
|
|
77
|
+
compact(roomId: string, mergedState: Uint8Array): Promise<void>;
|
|
78
|
+
drop(roomId: string): Promise<void>;
|
|
79
|
+
private removeLog;
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=persistence-s3.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"persistence-s3.d.ts","sourceRoot":"","sources":["../src/persistence-s3.ts"],"names":[],"mappings":"AA+BA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAEpD,iEAAiE;AACjE,MAAM,WAAW,YAAY;IAC3B,IAAI,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CAC1C;AAED,MAAM,WAAW,UAAU;IACzB,gBAAgB,EAAE,KAAK,KAAK,EAAE,cAAc,KAAK,OAAO,CAAC;IACzD,gBAAgB,EAAE,KAAK,KAAK,EAAE,cAAc,KAAK,OAAO,CAAC;IACzD,mBAAmB,EAAE,KAAK,KAAK,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC;IAC7E,oBAAoB,EAAE,KAAK,KAAK,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC;IAClF,iBAAiB,CAAC,EAAE,KAAK,KAAK,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC;CAC7E;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,UAAU,GAAG,MAAM,CAAC;IAC1B,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AACD,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,oBAAoB;IACnC,MAAM,EAAE,YAAY,CAAC;IACrB,QAAQ,EAAE,UAAU,CAAC;IACrB,MAAM,EAAE,MAAM,CAAC;IACf,2DAA2D;IAC3D,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,2DAA2D;IAC3D,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,qBAAa,aAAc,YAAW,WAAW;IAC/C,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAe;IACtC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAa;IAClC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC;;;;OAIG;IACH,OAAO,CAAC,aAAa,CAAK;gBAEd,IAAI,EAAE,oBAAoB;IAQtC;;;;OAIG;IACH,OAAO,CAAC,QAAQ;IAIhB,OAAO,CAAC,OAAO;IAIf;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,MAAM;YASA,cAAc;IAqBtB,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;IAqBhD,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC;IAiBzD,OAAO,CAAC,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC;IAa/D,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;YAQ3B,SAAS;CAaxB"}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
|
+
/**
|
|
5
|
+
* S3 persistence backend (spec §12.2).
|
|
6
|
+
*
|
|
7
|
+
* Implements the `Persistence` interface against any S3-compatible
|
|
8
|
+
* object store (AWS S3, R2, MinIO, etc.). To keep `@ifc-lite/collab-server`
|
|
9
|
+
* free of a hard `@aws-sdk/client-s3` dependency, this module accepts
|
|
10
|
+
* a tiny `S3Client`-shaped interface that the deployer fulfils — either
|
|
11
|
+
* by passing the real SDK directly (the AWS SDK satisfies the shape) or
|
|
12
|
+
* by writing a thin shim around their preferred storage client.
|
|
13
|
+
*
|
|
14
|
+
* Layout per room:
|
|
15
|
+
* - `s3://bucket/<prefix><safeRoomId>.snap` ← compacted state
|
|
16
|
+
* - `s3://bucket/<prefix><safeRoomId>.log/<ulid>.bin` ← rolling log frames
|
|
17
|
+
*
|
|
18
|
+
* Compaction overwrites `.snap` and truncates the log directory.
|
|
19
|
+
*
|
|
20
|
+
* Frame keys are ULID-like (sortable timestamp prefix + random suffix), not
|
|
21
|
+
* monotonic per-process counters, so multiple writers serving the same room
|
|
22
|
+
* never produce identical keys. They remain lexicographically sortable, which
|
|
23
|
+
* is what `load()` relies on to replay frames in append order.
|
|
24
|
+
*
|
|
25
|
+
* Room IDs are `encodeURIComponent`-escaped rather than replaced with `_`,
|
|
26
|
+
* so distinct room IDs never collapse to the same storage prefix.
|
|
27
|
+
*/
|
|
28
|
+
import { randomBytes } from 'node:crypto';
|
|
29
|
+
export class S3Persistence {
|
|
30
|
+
client;
|
|
31
|
+
cmds;
|
|
32
|
+
bucket;
|
|
33
|
+
prefix;
|
|
34
|
+
frameMaxBytes;
|
|
35
|
+
/**
|
|
36
|
+
* In-process monotonic counter — guarantees same-millisecond appends
|
|
37
|
+
* sort in submission order. Combined with `randomBytes` it stays
|
|
38
|
+
* globally unique across processes.
|
|
39
|
+
*/
|
|
40
|
+
appendCounter = 0;
|
|
41
|
+
constructor(opts) {
|
|
42
|
+
this.client = opts.client;
|
|
43
|
+
this.cmds = opts.commands;
|
|
44
|
+
this.bucket = opts.bucket;
|
|
45
|
+
this.prefix = opts.prefix ?? '';
|
|
46
|
+
this.frameMaxBytes = opts.frameMaxBytes ?? 1024 * 1024;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Map a room id to a safe, *unique* key segment. `encodeURIComponent`
|
|
50
|
+
* preserves distinct ids (so `a/b` and `a_b` never collide) while keeping
|
|
51
|
+
* the result safe for S3 keys.
|
|
52
|
+
*/
|
|
53
|
+
safeRoom(roomId) {
|
|
54
|
+
return encodeURIComponent(roomId);
|
|
55
|
+
}
|
|
56
|
+
snapKey(roomId) {
|
|
57
|
+
return `${this.prefix}${this.safeRoom(roomId)}.snap`;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Per-frame key composed of:
|
|
61
|
+
* - a fixed-width unix-ms timestamp (sorts by wall-clock order),
|
|
62
|
+
* - an in-process monotonic counter (breaks ties when two appends
|
|
63
|
+
* land in the same millisecond — without this, two same-ms
|
|
64
|
+
* appends from the same writer could replay out of order),
|
|
65
|
+
* - a random suffix (gives cross-process uniqueness when multiple
|
|
66
|
+
* server instances serve the same room).
|
|
67
|
+
*
|
|
68
|
+
* Lexicographic key ordering = chronological-then-submission ordering,
|
|
69
|
+
* which is what `load()` relies on.
|
|
70
|
+
*/
|
|
71
|
+
logKey(roomId) {
|
|
72
|
+
// 13-digit unix-ms is good through the year 2286; pad to 14 for safety.
|
|
73
|
+
const ts = Date.now().toString().padStart(14, '0');
|
|
74
|
+
this.appendCounter = (this.appendCounter + 1) >>> 0;
|
|
75
|
+
const seq = this.appendCounter.toString(16).padStart(8, '0');
|
|
76
|
+
const rand = randomBytes(8).toString('hex');
|
|
77
|
+
return `${this.prefix}${this.safeRoom(roomId)}.log/${ts}-${seq}-${rand}.bin`;
|
|
78
|
+
}
|
|
79
|
+
async getObjectBytes(key) {
|
|
80
|
+
const Get = this.cmds.GetObjectCommand;
|
|
81
|
+
try {
|
|
82
|
+
const res = await this.client.send(new Get({ Bucket: this.bucket, Key: key }));
|
|
83
|
+
const body = res.Body;
|
|
84
|
+
if (body?.transformToByteArray) {
|
|
85
|
+
return await body.transformToByteArray();
|
|
86
|
+
}
|
|
87
|
+
// Stream-shape body: collect chunks.
|
|
88
|
+
if (body && typeof body[Symbol.asyncIterator] === 'function') {
|
|
89
|
+
const chunks = [];
|
|
90
|
+
for await (const chunk of body)
|
|
91
|
+
chunks.push(chunk);
|
|
92
|
+
return concat(chunks);
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
if (isNotFound(err))
|
|
98
|
+
return null;
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async load(roomId) {
|
|
103
|
+
// Snapshot first, then concatenated log frames after it.
|
|
104
|
+
const snap = await this.getObjectBytes(this.snapKey(roomId));
|
|
105
|
+
const List = this.cmds.ListObjectsV2Command;
|
|
106
|
+
const list = (await this.client.send(new List({ Bucket: this.bucket, Prefix: `${this.prefix}${this.safeRoom(roomId)}.log/` })));
|
|
107
|
+
const keys = (list.Contents ?? [])
|
|
108
|
+
.map((c) => c.Key)
|
|
109
|
+
.filter((k) => typeof k === 'string')
|
|
110
|
+
.sort();
|
|
111
|
+
const frames = snap ? [snap] : [];
|
|
112
|
+
for (const key of keys) {
|
|
113
|
+
const bytes = await this.getObjectBytes(key);
|
|
114
|
+
if (bytes)
|
|
115
|
+
frames.push(bytes);
|
|
116
|
+
}
|
|
117
|
+
if (frames.length === 0)
|
|
118
|
+
return null;
|
|
119
|
+
return concat(frames);
|
|
120
|
+
}
|
|
121
|
+
async append(roomId, update) {
|
|
122
|
+
if (update.byteLength > this.frameMaxBytes) {
|
|
123
|
+
throw new Error(`@ifc-lite/collab-server: frame ${update.byteLength}B exceeds frameMaxBytes ${this.frameMaxBytes}`);
|
|
124
|
+
}
|
|
125
|
+
const Put = this.cmds.PutObjectCommand;
|
|
126
|
+
await this.client.send(new Put({
|
|
127
|
+
Bucket: this.bucket,
|
|
128
|
+
Key: this.logKey(roomId),
|
|
129
|
+
Body: Buffer.from(update),
|
|
130
|
+
ContentType: 'application/octet-stream',
|
|
131
|
+
}));
|
|
132
|
+
}
|
|
133
|
+
async compact(roomId, mergedState) {
|
|
134
|
+
const Put = this.cmds.PutObjectCommand;
|
|
135
|
+
await this.client.send(new Put({
|
|
136
|
+
Bucket: this.bucket,
|
|
137
|
+
Key: this.snapKey(roomId),
|
|
138
|
+
Body: Buffer.from(mergedState),
|
|
139
|
+
ContentType: 'application/octet-stream',
|
|
140
|
+
}));
|
|
141
|
+
await this.removeLog(roomId);
|
|
142
|
+
}
|
|
143
|
+
async drop(roomId) {
|
|
144
|
+
const Del = this.cmds.DeleteObjectCommand;
|
|
145
|
+
await Promise.all([
|
|
146
|
+
this.client.send(new Del({ Bucket: this.bucket, Key: this.snapKey(roomId) })).catch(swallowNotFound),
|
|
147
|
+
this.removeLog(roomId),
|
|
148
|
+
]);
|
|
149
|
+
}
|
|
150
|
+
async removeLog(roomId) {
|
|
151
|
+
const List = this.cmds.ListObjectsV2Command;
|
|
152
|
+
const Del = this.cmds.DeleteObjectCommand;
|
|
153
|
+
const list = (await this.client.send(new List({ Bucket: this.bucket, Prefix: `${this.prefix}${this.safeRoom(roomId)}.log/` })));
|
|
154
|
+
const keys = (list.Contents ?? []).map((c) => c.Key).filter((k) => typeof k === 'string');
|
|
155
|
+
await Promise.all(keys.map((k) => this.client.send(new Del({ Bucket: this.bucket, Key: k })).catch(swallowNotFound)));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function concat(arr) {
|
|
159
|
+
const total = arr.reduce((n, a) => n + a.byteLength, 0);
|
|
160
|
+
const out = new Uint8Array(total);
|
|
161
|
+
let o = 0;
|
|
162
|
+
for (const a of arr) {
|
|
163
|
+
out.set(a, o);
|
|
164
|
+
o += a.byteLength;
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
function isNotFound(err) {
|
|
169
|
+
if (!err || typeof err !== 'object')
|
|
170
|
+
return false;
|
|
171
|
+
const e = err;
|
|
172
|
+
if (e.name === 'NoSuchKey' || e.Code === 'NoSuchKey')
|
|
173
|
+
return true;
|
|
174
|
+
if (e.$metadata?.httpStatusCode === 404)
|
|
175
|
+
return true;
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
function swallowNotFound(err) {
|
|
179
|
+
if (isNotFound(err))
|
|
180
|
+
return;
|
|
181
|
+
throw err;
|
|
182
|
+
}
|
|
183
|
+
//# sourceMappingURL=persistence-s3.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"persistence-s3.js","sourceRoot":"","sources":["../src/persistence-s3.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export interface Persistence {
|
|
2
|
+
/** Load any saved updates for `roomId` and return them as a single merged blob. */
|
|
3
|
+
load(roomId: string): Promise<Uint8Array | null>;
|
|
4
|
+
/** Append `update` to `roomId`'s log. */
|
|
5
|
+
append(roomId: string, update: Uint8Array): Promise<void>;
|
|
6
|
+
/** Replace the room's log with a freshly compacted state (called periodically). */
|
|
7
|
+
compact(roomId: string, mergedState: Uint8Array): Promise<void>;
|
|
8
|
+
/** Hard-delete a room. */
|
|
9
|
+
drop(roomId: string): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
export declare class MemoryPersistence implements Persistence {
|
|
12
|
+
private readonly logs;
|
|
13
|
+
load(roomId: string): Promise<Uint8Array | null>;
|
|
14
|
+
append(roomId: string, update: Uint8Array): Promise<void>;
|
|
15
|
+
compact(roomId: string, mergedState: Uint8Array): Promise<void>;
|
|
16
|
+
drop(roomId: string): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
export interface FilePersistenceOptions {
|
|
19
|
+
/** Root directory for room logs. */
|
|
20
|
+
dataDir: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Append-only file-per-room persistence.
|
|
24
|
+
*
|
|
25
|
+
* Update layout: each `append()` writes one frame `[length:u32][bytes]`
|
|
26
|
+
* to `<dataDir>/<sanitizedRoomId>.log`. `load()` reads the file and
|
|
27
|
+
* returns the concatenated payload.
|
|
28
|
+
*
|
|
29
|
+
* Compaction rewrites the log atomically by writing to a temp file then
|
|
30
|
+
* renaming.
|
|
31
|
+
*/
|
|
32
|
+
export declare class FilePersistence implements Persistence {
|
|
33
|
+
private readonly dataDir;
|
|
34
|
+
constructor(opts: FilePersistenceOptions);
|
|
35
|
+
private logPath;
|
|
36
|
+
load(roomId: string): Promise<Uint8Array | null>;
|
|
37
|
+
append(roomId: string, update: Uint8Array): Promise<void>;
|
|
38
|
+
compact(roomId: string, mergedState: Uint8Array): Promise<void>;
|
|
39
|
+
drop(roomId: string): Promise<void>;
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=persistence.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"persistence.d.ts","sourceRoot":"","sources":["../src/persistence.ts"],"names":[],"mappings":"AAiBA,MAAM,WAAW,WAAW;IAC1B,mFAAmF;IACnF,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAAC;IACjD,yCAAyC;IACzC,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1D,mFAAmF;IACnF,OAAO,CAAC,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAChE,0BAA0B;IAC1B,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACrC;AAED,qBAAa,iBAAkB,YAAW,WAAW;IACnD,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAmC;IAElD,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;IAMhD,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC;IAMzD,OAAO,CAAC,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC;IAI/D,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAG1C;AAED,MAAM,WAAW,sBAAsB;IACrC,oCAAoC;IACpC,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;;;GASG;AACH,qBAAa,eAAgB,YAAW,WAAW;IACjD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;gBAErB,IAAI,EAAE,sBAAsB;IAKxC,OAAO,CAAC,OAAO;IAKT,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;IAkBhD,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC;IAQzD,OAAO,CAAC,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC;IAS/D,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAM1C"}
|