sh3-server 0.8.1 → 0.9.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 (48) hide show
  1. package/app/assets/index-DKuJNK2S.js +17 -0
  2. package/app/assets/index-DKuJNK2S.js.map +1 -0
  3. package/app/assets/index-DkC3EpjJ.css +1 -0
  4. package/app/index.html +2 -2
  5. package/dist/auth.d.ts +10 -3
  6. package/dist/auth.js +14 -22
  7. package/dist/caller.d.ts +17 -0
  8. package/dist/caller.js +55 -0
  9. package/dist/doc-store/conflicts.d.ts +19 -0
  10. package/dist/doc-store/conflicts.js +79 -0
  11. package/dist/doc-store/index.d.ts +11 -0
  12. package/dist/doc-store/index.js +22 -0
  13. package/dist/doc-store/meta.d.ts +11 -0
  14. package/dist/doc-store/meta.js +37 -0
  15. package/dist/doc-store/policy.d.ts +15 -0
  16. package/dist/doc-store/policy.js +85 -0
  17. package/dist/doc-store/reserved.d.ts +7 -0
  18. package/dist/doc-store/reserved.js +26 -0
  19. package/dist/doc-store/roles.d.ts +12 -0
  20. package/dist/doc-store/roles.js +15 -0
  21. package/dist/doc-store/store.d.ts +71 -0
  22. package/dist/doc-store/store.js +336 -0
  23. package/dist/doc-store/tick.d.ts +13 -0
  24. package/dist/doc-store/tick.js +52 -0
  25. package/dist/fs-backend.d.ts +10 -0
  26. package/dist/fs-backend.js +105 -0
  27. package/dist/index.d.ts +1 -0
  28. package/dist/index.js +32 -5
  29. package/dist/keys.d.ts +35 -19
  30. package/dist/keys.js +187 -49
  31. package/dist/migrations/sync-grants.d.ts +7 -0
  32. package/dist/migrations/sync-grants.js +27 -0
  33. package/dist/packages.d.ts +3 -2
  34. package/dist/packages.js +5 -5
  35. package/dist/routes/admin.js +7 -3
  36. package/dist/routes/docs.d.ts +11 -7
  37. package/dist/routes/docs.js +88 -122
  38. package/dist/routes/keys.d.ts +21 -0
  39. package/dist/routes/keys.js +166 -0
  40. package/dist/scope.d.ts +11 -0
  41. package/dist/scope.js +45 -0
  42. package/dist/shard-router.d.ts +10 -4
  43. package/dist/shard-router.js +130 -49
  44. package/dist/shell-shard/index.d.ts +4 -1
  45. package/package.json +1 -1
  46. package/app/assets/index-C3rCTpjL.js +0 -17
  47. package/app/assets/index-C3rCTpjL.js.map +0 -1
  48. package/app/assets/index-GfhVhkjD.css +0 -1
@@ -0,0 +1,71 @@
1
+ /**
2
+ * TenantDocStore — single entry point for tenant-document operations.
3
+ *
4
+ * Owns the write pipeline: version bump (primary) or pending (replica),
5
+ * policy-resolved syncMode caching, reserved-metadata filter, tick advance,
6
+ * conflict bucket on Mode B mismatch. Consumed by the docs HTTP router
7
+ * and by the ServerShardContext.documents(tenant) API.
8
+ */
9
+ import type { DocumentMeta, SyncPolicy, ConflictFile } from 'sh3-core';
10
+ import type { PolicyCache } from './policy.js';
11
+ import type { TickCounter } from './tick.js';
12
+ import type { PeerRoles } from './roles.js';
13
+ import type { ConflictBucket, ConflictRef } from './conflicts.js';
14
+ export interface TenantDocStoreDeps {
15
+ dataDir: string;
16
+ policy: PolicyCache;
17
+ tick: TickCounter;
18
+ roles: PeerRoles;
19
+ conflicts: ConflictBucket;
20
+ }
21
+ export interface ApplyFromPeerInput {
22
+ shardId: string;
23
+ path: string;
24
+ content: string | Buffer;
25
+ incomingVersion: number;
26
+ expectedLocalVersion: number;
27
+ origin: string;
28
+ deleted?: boolean;
29
+ metadata?: Record<string, unknown>;
30
+ }
31
+ export type ApplyResult = {
32
+ applied: true;
33
+ version: number;
34
+ } | {
35
+ applied: false;
36
+ reason: 'stale' | 'conflict' | 'conflict-extended';
37
+ };
38
+ export declare class TenantDocStore {
39
+ #private;
40
+ readonly roles: PeerRoles;
41
+ constructor(deps: TenantDocStoreDeps);
42
+ /** Root filesystem directory this store reads/writes under. */
43
+ get dataDir(): string;
44
+ /** Synchronous tenant enumeration for shard-ctx's `tenants()` entry point. */
45
+ listTenantsSync(): string[];
46
+ /**
47
+ * Persist `__sync__/policy.json` for a tenant and refresh the cached
48
+ * `syncMode` on every existing doc so subsequent reads match the new
49
+ * policy without a restart.
50
+ */
51
+ writePolicy(tenant: string, policy: SyncPolicy): Promise<void>;
52
+ read(tenant: string, shardId: string, path: string): Promise<string | null>;
53
+ readMeta(tenant: string, shardId: string, path: string): Promise<Record<string, unknown> | null>;
54
+ exists(tenant: string, shardId: string, path: string): Promise<boolean>;
55
+ list(tenant: string, shardId: string): Promise<DocumentMeta[]>;
56
+ listAll(tenant: string): Promise<(DocumentMeta & {
57
+ shardId: string;
58
+ })[]>;
59
+ getTick(tenant: string): Promise<number>;
60
+ readPolicy(tenant: string): Promise<SyncPolicy | null>;
61
+ invalidatePolicy(tenant: string): void;
62
+ write(tenant: string, shardId: string, path: string, content: string | Buffer, metadata?: Record<string, unknown>): Promise<{
63
+ version: number;
64
+ syncState: 'synced' | 'pending';
65
+ }>;
66
+ delete(tenant: string, shardId: string, path: string): Promise<void>;
67
+ applyFromPeer(tenant: string, input: ApplyFromPeerInput): Promise<ApplyResult>;
68
+ listConflicts(tenant: string): Promise<ConflictRef[]>;
69
+ readConflict(tenant: string, shardId: string, path: string): Promise<ConflictFile | null>;
70
+ resolveConflict(tenant: string, shardId: string, path: string, choice: 'local' | string | Buffer): Promise<void>;
71
+ }
@@ -0,0 +1,336 @@
1
+ /**
2
+ * TenantDocStore — single entry point for tenant-document operations.
3
+ *
4
+ * Owns the write pipeline: version bump (primary) or pending (replica),
5
+ * policy-resolved syncMode caching, reserved-metadata filter, tick advance,
6
+ * conflict bucket on Mode B mismatch. Consumed by the docs HTTP router
7
+ * and by the ServerShardContext.documents(tenant) API.
8
+ */
9
+ import { readFile, writeFile, mkdir, rm, readdir, stat } from 'node:fs/promises';
10
+ import { dirname, join, relative } from 'node:path';
11
+ import { resolveSyncMode } from './policy.js';
12
+ import { filterReservedMeta } from './reserved.js';
13
+ import { readMeta, writeMeta } from './meta.js';
14
+ export class TenantDocStore {
15
+ #dataDir;
16
+ #policy;
17
+ #tick;
18
+ roles;
19
+ #conflicts;
20
+ constructor(deps) {
21
+ this.#dataDir = deps.dataDir;
22
+ this.#policy = deps.policy;
23
+ this.#tick = deps.tick;
24
+ this.roles = deps.roles;
25
+ this.#conflicts = deps.conflicts;
26
+ }
27
+ /** Root filesystem directory this store reads/writes under. */
28
+ get dataDir() { return this.#dataDir; }
29
+ /** Synchronous tenant enumeration for shard-ctx's `tenants()` entry point. */
30
+ listTenantsSync() {
31
+ const { readdirSync, existsSync } = require('node:fs');
32
+ const root = join(this.#dataDir, 'docs');
33
+ if (!existsSync(root))
34
+ return [];
35
+ return readdirSync(root, { withFileTypes: true })
36
+ .filter((e) => e.isDirectory())
37
+ .map((e) => e.name);
38
+ }
39
+ /**
40
+ * Persist `__sync__/policy.json` for a tenant and refresh the cached
41
+ * `syncMode` on every existing doc so subsequent reads match the new
42
+ * policy without a restart.
43
+ */
44
+ async writePolicy(tenant, policy) {
45
+ const p = join(this.#dataDir, 'docs', tenant, '__sync__', 'policy.json');
46
+ await mkdir(dirname(p), { recursive: true });
47
+ await writeFile(p, JSON.stringify(policy, null, 2));
48
+ this.#policy.invalidate(tenant);
49
+ const all = await this.listAll(tenant);
50
+ for (const entry of all) {
51
+ const meta = await readMeta(this.#dataDir, tenant, entry.shardId, entry.path);
52
+ if (!meta)
53
+ continue;
54
+ const newMode = resolveSyncMode(policy, entry.path);
55
+ if (meta.syncMode !== newMode) {
56
+ await writeMeta(this.#dataDir, tenant, entry.shardId, entry.path, { ...meta, syncMode: newMode });
57
+ }
58
+ }
59
+ }
60
+ // ---------- path helpers ----------
61
+ #contentPath(tenant, shardId, path) {
62
+ return join(this.#dataDir, 'docs', tenant, shardId, path);
63
+ }
64
+ // ---------- reads ----------
65
+ async read(tenant, shardId, path) {
66
+ try {
67
+ return await readFile(this.#contentPath(tenant, shardId, path), 'utf-8');
68
+ }
69
+ catch (err) {
70
+ if (err?.code === 'ENOENT')
71
+ return null;
72
+ throw err;
73
+ }
74
+ }
75
+ async readMeta(tenant, shardId, path) {
76
+ return readMeta(this.#dataDir, tenant, shardId, path);
77
+ }
78
+ async exists(tenant, shardId, path) {
79
+ try {
80
+ await stat(this.#contentPath(tenant, shardId, path));
81
+ return true;
82
+ }
83
+ catch {
84
+ return false;
85
+ }
86
+ }
87
+ async list(tenant, shardId) {
88
+ const root = join(this.#dataDir, 'docs', tenant, shardId);
89
+ return this.#enumerate(root, root, tenant, shardId);
90
+ }
91
+ async listAll(tenant) {
92
+ const root = join(this.#dataDir, 'docs', tenant);
93
+ const out = [];
94
+ let entries;
95
+ try {
96
+ entries = await readdir(root, { withFileTypes: true });
97
+ }
98
+ catch (err) {
99
+ if (err?.code === 'ENOENT')
100
+ return [];
101
+ throw err;
102
+ }
103
+ for (const e of entries) {
104
+ if (!e.isDirectory())
105
+ continue;
106
+ if (e.name.startsWith('__'))
107
+ continue; // reserved
108
+ const shardDir = join(root, e.name);
109
+ for (const m of await this.#enumerate(shardDir, shardDir, tenant, e.name)) {
110
+ out.push({ ...m, shardId: e.name });
111
+ }
112
+ }
113
+ return out;
114
+ }
115
+ async #enumerate(current, base, tenant, shardId) {
116
+ const out = [];
117
+ let entries;
118
+ try {
119
+ entries = await readdir(current, { withFileTypes: true });
120
+ }
121
+ catch (err) {
122
+ if (err?.code === 'ENOENT')
123
+ return out;
124
+ throw err;
125
+ }
126
+ for (const e of entries) {
127
+ const full = join(current, e.name);
128
+ if (e.isDirectory()) {
129
+ out.push(...(await this.#enumerate(full, base, tenant, shardId)));
130
+ continue;
131
+ }
132
+ const rel = relative(base, full).split(/[/\\]/).join('/');
133
+ const s = await stat(full);
134
+ const meta = await readMeta(this.#dataDir, tenant, shardId, rel);
135
+ out.push({
136
+ path: rel,
137
+ size: s.size,
138
+ lastModified: s.mtimeMs,
139
+ ...(meta ?? {}),
140
+ });
141
+ }
142
+ return out;
143
+ }
144
+ // ---------- policy / tick passthroughs ----------
145
+ async getTick(tenant) {
146
+ return this.#tick.get(tenant);
147
+ }
148
+ async readPolicy(tenant) {
149
+ return this.#policy.get(tenant);
150
+ }
151
+ invalidatePolicy(tenant) {
152
+ this.#policy.invalidate(tenant);
153
+ }
154
+ // ---------- Mode A write ----------
155
+ async write(tenant, shardId, path, content, metadata) {
156
+ const role = this.roles.get(tenant);
157
+ const policy = await this.#policy.get(tenant);
158
+ const syncMode = resolveSyncMode(policy, path);
159
+ const prev = await readMeta(this.#dataDir, tenant, shardId, path);
160
+ const prevKnown = typeof prev?.lastKnownVersion === 'number' ? prev.lastKnownVersion : 0;
161
+ let version;
162
+ let syncState;
163
+ let lastKnownVersion;
164
+ if (role === 'primary') {
165
+ version = prevKnown + 1;
166
+ lastKnownVersion = version;
167
+ syncState = 'synced';
168
+ }
169
+ else {
170
+ // replica
171
+ version = prevKnown;
172
+ lastKnownVersion = prevKnown;
173
+ syncState = syncMode === 'local-only' ? 'synced' : 'pending';
174
+ }
175
+ // Write content first.
176
+ const cp = this.#contentPath(tenant, shardId, path);
177
+ await mkdir(dirname(cp), { recursive: true });
178
+ await writeFile(cp, content);
179
+ // Then metadata.
180
+ const custom = filterReservedMeta(metadata);
181
+ const mergedMeta = {
182
+ ...custom,
183
+ version,
184
+ syncMode,
185
+ syncState,
186
+ lastKnownVersion,
187
+ };
188
+ await writeMeta(this.#dataDir, tenant, shardId, path, mergedMeta);
189
+ if (role === 'primary')
190
+ await this.#tick.bump(tenant);
191
+ return { version, syncState };
192
+ }
193
+ // ---------- Mode A delete ----------
194
+ async delete(tenant, shardId, path) {
195
+ const role = this.roles.get(tenant);
196
+ const prev = await readMeta(this.#dataDir, tenant, shardId, path);
197
+ const prevKnown = typeof prev?.lastKnownVersion === 'number' ? prev.lastKnownVersion : 0;
198
+ // Remove content.
199
+ try {
200
+ await rm(this.#contentPath(tenant, shardId, path));
201
+ }
202
+ catch (err) {
203
+ if (err?.code !== 'ENOENT')
204
+ throw err;
205
+ }
206
+ let version;
207
+ let syncState;
208
+ let lastKnownVersion;
209
+ if (role === 'primary') {
210
+ version = prevKnown + 1;
211
+ lastKnownVersion = version;
212
+ syncState = 'synced';
213
+ }
214
+ else {
215
+ version = prevKnown;
216
+ lastKnownVersion = prevKnown;
217
+ syncState = 'pending';
218
+ }
219
+ await writeMeta(this.#dataDir, tenant, shardId, path, {
220
+ version,
221
+ lastKnownVersion,
222
+ syncState,
223
+ deleted: true,
224
+ });
225
+ if (role === 'primary')
226
+ await this.#tick.bump(tenant);
227
+ }
228
+ // ---------- Mode B — applyFromPeer ----------
229
+ async applyFromPeer(tenant, input) {
230
+ const { shardId, path, content, incomingVersion, expectedLocalVersion, origin } = input;
231
+ const prev = await readMeta(this.#dataDir, tenant, shardId, path);
232
+ const prevVersion = typeof prev?.version === 'number' ? prev.version : 0;
233
+ const prevState = prev?.syncState;
234
+ if (!prev) {
235
+ await this.#overwriteFromPeer(tenant, shardId, path, content, incomingVersion, origin, input.deleted);
236
+ return { applied: true, version: incomingVersion };
237
+ }
238
+ if (prevState === 'synced') {
239
+ if (prevVersion === expectedLocalVersion) {
240
+ await this.#overwriteFromPeer(tenant, shardId, path, content, incomingVersion, origin, input.deleted);
241
+ return { applied: true, version: incomingVersion };
242
+ }
243
+ return { applied: false, reason: 'stale' };
244
+ }
245
+ if (prevState === 'pending') {
246
+ const localContent = (await this.read(tenant, shardId, path)) ?? '';
247
+ await this.#conflicts.append(tenant, shardId, path, {
248
+ origin: 'local',
249
+ version: prevVersion,
250
+ content: localContent,
251
+ at: Date.now(),
252
+ });
253
+ await this.#conflicts.append(tenant, shardId, path, {
254
+ origin,
255
+ version: incomingVersion,
256
+ content: typeof content === 'string' ? content : content.toString('utf-8'),
257
+ at: Date.now(),
258
+ });
259
+ const merged = { ...(prev ?? {}), syncState: 'conflict' };
260
+ await writeMeta(this.#dataDir, tenant, shardId, path, merged);
261
+ return { applied: false, reason: 'conflict' };
262
+ }
263
+ // prevState === 'conflict'
264
+ await this.#conflicts.append(tenant, shardId, path, {
265
+ origin,
266
+ version: incomingVersion,
267
+ content: typeof content === 'string' ? content : content.toString('utf-8'),
268
+ at: Date.now(),
269
+ });
270
+ return { applied: false, reason: 'conflict-extended' };
271
+ }
272
+ async #overwriteFromPeer(tenant, shardId, path, content, version, origin, deleted) {
273
+ const cp = this.#contentPath(tenant, shardId, path);
274
+ if (deleted) {
275
+ try {
276
+ await rm(cp);
277
+ }
278
+ catch (err) {
279
+ if (err?.code !== 'ENOENT')
280
+ throw err;
281
+ }
282
+ }
283
+ else {
284
+ await mkdir(dirname(cp), { recursive: true });
285
+ await writeFile(cp, content);
286
+ }
287
+ const policy = await this.#policy.get(tenant);
288
+ const syncMode = resolveSyncMode(policy, path);
289
+ await writeMeta(this.#dataDir, tenant, shardId, path, {
290
+ version,
291
+ lastKnownVersion: version,
292
+ syncMode,
293
+ syncState: 'synced',
294
+ origin,
295
+ lastSyncedAt: Date.now(),
296
+ ...(deleted ? { deleted: true } : {}),
297
+ });
298
+ // Primary accepting a push advances tick; replica pulling doesn't.
299
+ if (this.roles.get(tenant) === 'primary')
300
+ await this.#tick.bump(tenant);
301
+ }
302
+ // ---------- conflicts ----------
303
+ async listConflicts(tenant) {
304
+ return this.#conflicts.list(tenant);
305
+ }
306
+ async readConflict(tenant, shardId, path) {
307
+ return this.#conflicts.read(tenant, shardId, path);
308
+ }
309
+ async resolveConflict(tenant, shardId, path, choice) {
310
+ const cf = await this.#conflicts.read(tenant, shardId, path);
311
+ if (!cf)
312
+ return;
313
+ let content;
314
+ if (choice === 'local') {
315
+ const branch = cf.branches.find((b) => b.origin === 'local');
316
+ if (!branch)
317
+ throw new Error('No local branch to resolve');
318
+ content = branch.content;
319
+ }
320
+ else if (typeof choice === 'string') {
321
+ const branch = cf.branches.find((b) => b.origin === choice);
322
+ if (!branch)
323
+ throw new Error(`No branch with origin ${choice}`);
324
+ content = branch.content;
325
+ }
326
+ else {
327
+ content = choice;
328
+ }
329
+ // Clear the conflict FIRST so the subsequent write doesn't see stale state.
330
+ await this.#conflicts.clear(tenant, shardId, path);
331
+ // Force syncState back to 'synced' baseline so Mode A write can transition normally.
332
+ const prev = (await readMeta(this.#dataDir, tenant, shardId, path)) ?? {};
333
+ await writeMeta(this.#dataDir, tenant, shardId, path, { ...prev, syncState: 'synced' });
334
+ await this.write(tenant, shardId, path, content);
335
+ }
336
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Per-tenant monotonic tick counter. Lives at
3
+ * {dataDir}/docs/<tenant>/__sync__/tick.json => { tick: number, bumpedAt: number }
4
+ *
5
+ * Incremented on every version-advancing write. Never decreases. Persisted
6
+ * synchronously before write acknowledgement. In-memory cached after first read.
7
+ */
8
+ export declare class TickCounter {
9
+ #private;
10
+ constructor(dataDir: string);
11
+ get(tenant: string): Promise<number>;
12
+ bump(tenant: string): Promise<number>;
13
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Per-tenant monotonic tick counter. Lives at
3
+ * {dataDir}/docs/<tenant>/__sync__/tick.json => { tick: number, bumpedAt: number }
4
+ *
5
+ * Incremented on every version-advancing write. Never decreases. Persisted
6
+ * synchronously before write acknowledgement. In-memory cached after first read.
7
+ */
8
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
9
+ import { dirname, join } from 'node:path';
10
+ export class TickCounter {
11
+ #dataDir;
12
+ #byTenant = new Map();
13
+ constructor(dataDir) {
14
+ this.#dataDir = dataDir;
15
+ }
16
+ async get(tenant) {
17
+ if (this.#byTenant.has(tenant))
18
+ return this.#byTenant.get(tenant);
19
+ const loaded = await this.#load(tenant);
20
+ this.#byTenant.set(tenant, loaded);
21
+ return loaded;
22
+ }
23
+ async bump(tenant) {
24
+ const current = await this.get(tenant);
25
+ const next = current + 1;
26
+ this.#byTenant.set(tenant, next);
27
+ await this.#persist(tenant, next);
28
+ return next;
29
+ }
30
+ #path(tenant) {
31
+ return join(this.#dataDir, 'docs', tenant, '__sync__', 'tick.json');
32
+ }
33
+ async #load(tenant) {
34
+ try {
35
+ const raw = await readFile(this.#path(tenant), 'utf-8');
36
+ const parsed = JSON.parse(raw);
37
+ return typeof parsed.tick === 'number' && Number.isFinite(parsed.tick)
38
+ ? Math.max(0, Math.floor(parsed.tick))
39
+ : 0;
40
+ }
41
+ catch (err) {
42
+ if (err?.code === 'ENOENT')
43
+ return 0;
44
+ throw err;
45
+ }
46
+ }
47
+ async #persist(tenant, value) {
48
+ const p = this.#path(tenant);
49
+ await mkdir(dirname(p), { recursive: true });
50
+ await writeFile(p, JSON.stringify({ tick: value, bumpedAt: Date.now() }));
51
+ }
52
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Filesystem-backed DocumentBackend for sh3-server.
3
+ *
4
+ * Storage layout mirrors the docs HTTP API:
5
+ * <docsDir>/<tenantId>/<shardId>/<path>
6
+ *
7
+ * where docsDir = <dataDir>/docs
8
+ */
9
+ import type { DocumentBackend } from 'sh3-core';
10
+ export declare function createFsDocumentBackend(dataDir: string): DocumentBackend;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Filesystem-backed DocumentBackend for sh3-server.
3
+ *
4
+ * Storage layout mirrors the docs HTTP API:
5
+ * <docsDir>/<tenantId>/<shardId>/<path>
6
+ *
7
+ * where docsDir = <dataDir>/docs
8
+ */
9
+ import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync, statSync, readdirSync, } from 'node:fs';
10
+ import { join, dirname, relative, resolve, sep } from 'node:path';
11
+ function collectFiles(dir, base) {
12
+ const results = [];
13
+ if (!existsSync(dir))
14
+ return results;
15
+ const entries = readdirSync(dir, { withFileTypes: true });
16
+ for (const entry of entries) {
17
+ const full = join(dir, entry.name);
18
+ if (entry.isDirectory()) {
19
+ results.push(...collectFiles(full, base));
20
+ }
21
+ else {
22
+ const stat = statSync(full);
23
+ results.push({
24
+ path: relative(base, full).replace(/\\/g, '/'),
25
+ size: stat.size,
26
+ lastModified: stat.mtimeMs,
27
+ });
28
+ }
29
+ }
30
+ return results;
31
+ }
32
+ class FsDocumentBackend {
33
+ #docsDir;
34
+ constructor(dataDir) {
35
+ this.#docsDir = join(dataDir, 'docs');
36
+ }
37
+ #resolve(tenantId, shardId, path) {
38
+ const base = resolve(this.#docsDir, tenantId, shardId);
39
+ const resolved = resolve(base, path);
40
+ if (resolved !== base && !resolved.startsWith(base + sep)) {
41
+ throw new Error(`Path escapes tenant/shard boundary: ${path}`);
42
+ }
43
+ return resolved;
44
+ }
45
+ async read(tenantId, shardId, path) {
46
+ const resolved = this.#resolve(tenantId, shardId, path);
47
+ if (!existsSync(resolved))
48
+ return null;
49
+ const buf = readFileSync(resolved);
50
+ try {
51
+ return new TextDecoder('utf-8', { fatal: true }).decode(buf);
52
+ }
53
+ catch {
54
+ return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
55
+ }
56
+ }
57
+ async write(tenantId, shardId, path, content) {
58
+ const resolved = this.#resolve(tenantId, shardId, path);
59
+ mkdirSync(dirname(resolved), { recursive: true });
60
+ if (typeof content === 'string') {
61
+ writeFileSync(resolved, content, 'utf-8');
62
+ }
63
+ else {
64
+ writeFileSync(resolved, Buffer.from(content));
65
+ }
66
+ }
67
+ async delete(tenantId, shardId, path) {
68
+ const resolved = this.#resolve(tenantId, shardId, path);
69
+ if (existsSync(resolved))
70
+ unlinkSync(resolved);
71
+ }
72
+ async list(tenantId, shardId) {
73
+ const dir = join(this.#docsDir, tenantId, shardId);
74
+ return collectFiles(dir, dir);
75
+ }
76
+ async exists(tenantId, shardId, path) {
77
+ return existsSync(this.#resolve(tenantId, shardId, path));
78
+ }
79
+ async listAllShards(tenantId) {
80
+ const tenantDir = join(this.#docsDir, tenantId);
81
+ if (!existsSync(tenantDir))
82
+ return [];
83
+ return readdirSync(tenantDir, { withFileTypes: true })
84
+ .filter((e) => e.isDirectory())
85
+ .map((e) => e.name);
86
+ }
87
+ async listAllDocuments(tenantId) {
88
+ const tenantDir = join(this.#docsDir, tenantId);
89
+ if (!existsSync(tenantDir))
90
+ return [];
91
+ const out = [];
92
+ for (const entry of readdirSync(tenantDir, { withFileTypes: true })) {
93
+ if (!entry.isDirectory())
94
+ continue;
95
+ const shardDir = join(tenantDir, entry.name);
96
+ for (const f of collectFiles(shardDir, shardDir)) {
97
+ out.push({ ...f, shardId: entry.name });
98
+ }
99
+ }
100
+ return out;
101
+ }
102
+ }
103
+ export function createFsDocumentBackend(dataDir) {
104
+ return new FsDocumentBackend(dataDir);
105
+ }
package/dist/index.d.ts CHANGED
@@ -29,6 +29,7 @@ export declare function createServer(options?: ServerOptions): Promise<{
29
29
  users: UserStore;
30
30
  sessions: SessionStore;
31
31
  settings: SettingsStore;
32
+ docStore: import("./doc-store/store.js").TenantDocStore;
32
33
  start(): Promise<void>;
33
34
  }>;
34
35
  export { KeyStore } from './keys.js';