@dxos/client-services 0.14.0 → 0.16.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/dist/lib/chunk-client-services-stack.mjs +799 -573
- package/dist/lib/chunk-client-services-stack.mjs.map +1 -1
- package/dist/lib/index.mjs +139 -131
- package/dist/lib/index.mjs.map +1 -1
- package/dist/lib/testing.mjs +1 -1
- package/dist/lib/testing.mjs.map +1 -1
- package/dist/types/src/Events.d.ts +2 -0
- package/dist/types/src/Events.d.ts.map +1 -1
- package/dist/types/src/internal/blobs/blob-replication.d.ts +28 -0
- package/dist/types/src/internal/blobs/blob-replication.d.ts.map +1 -0
- package/dist/types/src/internal/blobs/blob-store-service.d.ts +10 -0
- package/dist/types/src/internal/blobs/blob-store-service.d.ts.map +1 -0
- package/dist/types/src/internal/blobs/blob-store-service.test.d.ts +2 -0
- package/dist/types/src/internal/blobs/blob-store-service.test.d.ts.map +1 -0
- package/dist/types/src/internal/blobs/index.d.ts +4 -0
- package/dist/types/src/internal/blobs/index.d.ts.map +1 -0
- package/dist/types/src/internal/blobs/local-blob-store.d.ts +21 -0
- package/dist/types/src/internal/blobs/local-blob-store.d.ts.map +1 -0
- package/dist/types/src/internal/blobs/local-blob-store.test.d.ts +2 -0
- package/dist/types/src/internal/blobs/local-blob-store.test.d.ts.map +1 -0
- package/dist/types/src/internal/services/layer-specs.d.ts +6 -0
- package/dist/types/src/internal/services/layer-specs.d.ts.map +1 -1
- package/dist/types/src/internal/services/service-stack.d.ts.map +1 -1
- package/dist/types/src/internal/space-export/serialized-space-writer.d.ts +4 -1
- package/dist/types/src/internal/space-export/serialized-space-writer.d.ts.map +1 -1
- package/dist/types/src/internal/spaces/spaces-service.d.ts.map +1 -1
- package/dist/types/src/internal/storage/index.d.ts +1 -0
- package/dist/types/src/internal/storage/index.d.ts.map +1 -1
- package/dist/types/src/internal/storage/storage-watchdog.d.ts +17 -0
- package/dist/types/src/internal/storage/storage-watchdog.d.ts.map +1 -0
- package/dist/types/src/internal/storage/storage-watchdog.test.d.ts +2 -0
- package/dist/types/src/internal/storage/storage-watchdog.test.d.ts.map +1 -0
- package/dist/types/src/internal/worker/worker-runtime.d.ts.map +1 -1
- package/dist/types/src/migrations/blobs/index.d.ts +10 -0
- package/dist/types/src/migrations/blobs/index.d.ts.map +1 -0
- package/dist/types/src/version.d.ts +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +43 -42
- package/src/Events.ts +3 -0
- package/src/internal/blobs/blob-replication.ts +80 -0
- package/src/internal/blobs/blob-store-service.test.ts +76 -0
- package/src/internal/blobs/blob-store-service.ts +45 -0
- package/src/internal/blobs/index.ts +7 -0
- package/src/internal/blobs/local-blob-store.test.ts +53 -0
- package/src/internal/blobs/local-blob-store.ts +89 -0
- package/src/internal/services/layer-specs.ts +55 -0
- package/src/internal/services/service-stack.ts +2 -0
- package/src/internal/space-export/serialized-space-writer.ts +16 -9
- package/src/internal/spaces/spaces-service.ts +44 -6
- package/src/internal/storage/index.ts +1 -0
- package/src/internal/storage/storage-watchdog.test.ts +46 -0
- package/src/internal/storage/storage-watchdog.ts +58 -0
- package/src/internal/worker/worker-runtime.ts +2 -0
- package/src/migrations/blobs/0001_init.sql +17 -0
- package/src/migrations/blobs/index.ts +18 -0
- package/src/migrations/migrations.test.ts +3 -0
- package/src/version.ts +1 -1
|
@@ -66,6 +66,7 @@ import * as SpacesContract from '../../contracts/spaces.ts';
|
|
|
66
66
|
import * as Readiness from '../../Readiness.ts';
|
|
67
67
|
import {
|
|
68
68
|
SpaceArchiveWriter,
|
|
69
|
+
collectFeedMessages,
|
|
69
70
|
detectSpaceArchiveFormat,
|
|
70
71
|
extractSpaceArchive,
|
|
71
72
|
objJsonToObjectStructure,
|
|
@@ -395,8 +396,13 @@ export class SpacesServiceImpl implements SpacesService.Handlers {
|
|
|
395
396
|
const space = dataSpaceManager.getSpaceById(request.spaceId) ?? raise(new Error('Space not found'));
|
|
396
397
|
|
|
397
398
|
const format = request.format ?? SpacesService.SpaceArchiveFormat.enums.BINARY;
|
|
398
|
-
|
|
399
|
-
|
|
399
|
+
// A binary archive holds whole automerge documents, which cannot be narrowed to a few feeds.
|
|
400
|
+
if (format === SpacesService.SpaceArchiveFormat.enums.JSON || request.feedIds) {
|
|
401
|
+
const archive = await writeSerializedSpaceArchive({
|
|
402
|
+
space,
|
|
403
|
+
echoHost: this._echoHost,
|
|
404
|
+
...(request.feedIds ? { feedIds: request.feedIds } : {}),
|
|
405
|
+
});
|
|
400
406
|
return { archive };
|
|
401
407
|
}
|
|
402
408
|
|
|
@@ -445,6 +451,20 @@ export class SpacesServiceImpl implements SpacesService.Handlers {
|
|
|
445
451
|
const dataSpaceManager = await this._getDataSpaceManager();
|
|
446
452
|
|
|
447
453
|
const format = request.archive.format ?? detectSpaceArchiveFormat(request.archive);
|
|
454
|
+
if (request.targetSpaceId) {
|
|
455
|
+
assertArgument(
|
|
456
|
+
format === SpacesService.SpaceArchiveFormat.enums.JSON,
|
|
457
|
+
'archive',
|
|
458
|
+
'Only a JSON archive can be imported into an existing space',
|
|
459
|
+
);
|
|
460
|
+
assertArgument(SpaceId.isValid(request.targetSpaceId), 'targetSpaceId', 'Invalid space ID');
|
|
461
|
+
const target =
|
|
462
|
+
dataSpaceManager.getSpaceById(request.targetSpaceId) ?? raise(new Error('Target space not found'));
|
|
463
|
+
await this._hydrateSpaceFromSerialized(target, readSerializedSpaceArchive(request.archive), {
|
|
464
|
+
merge: true,
|
|
465
|
+
});
|
|
466
|
+
return { newSpaceId: target.id };
|
|
467
|
+
}
|
|
448
468
|
if (format === SpacesService.SpaceArchiveFormat.enums.JSON) {
|
|
449
469
|
const serialized = readSerializedSpaceArchive(request.archive);
|
|
450
470
|
const space = await dataSpaceManager.createSpace(ctx, { tags: request.tags });
|
|
@@ -477,6 +497,7 @@ export class SpacesServiceImpl implements SpacesService.Handlers {
|
|
|
477
497
|
private async '_hydrateSpaceFromSerialized'(
|
|
478
498
|
space: DataSpace,
|
|
479
499
|
serialized: ReturnType<typeof readSerializedSpaceArchive>,
|
|
500
|
+
{ merge = false }: { merge?: boolean } = {},
|
|
480
501
|
): Promise<void> {
|
|
481
502
|
const databaseRoot = space.databaseRoot;
|
|
482
503
|
assertState(databaseRoot, 'Space database root is not ready');
|
|
@@ -486,17 +507,30 @@ export class SpacesServiceImpl implements SpacesService.Handlers {
|
|
|
486
507
|
doc.objects = {};
|
|
487
508
|
}
|
|
488
509
|
for (const obj of serialized.objects) {
|
|
510
|
+
// Merging into a live space keeps its own copy of an object the archive also holds, whether it is inline in
|
|
511
|
+
// the root document or in a linked document of its own.
|
|
512
|
+
if (merge && (doc.objects[obj.id] || doc.links?.[obj.id])) {
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
489
515
|
doc.objects[obj.id] = objJsonToObjectStructure(obj);
|
|
490
516
|
}
|
|
491
517
|
});
|
|
492
518
|
|
|
493
519
|
for (const feed of serialized.feeds ?? []) {
|
|
494
|
-
|
|
520
|
+
const namespace =
|
|
521
|
+
feed.namespace === 'trace' ? FeedProtocol.WellKnownNamespaces.trace : FeedProtocol.WellKnownNamespaces.data;
|
|
522
|
+
const held = merge
|
|
523
|
+
? new Set(
|
|
524
|
+
(await collectFeedMessages(this._echoHost, space.id, feed.feedObjectId, feed.namespace)).map(
|
|
525
|
+
(message) => message.id,
|
|
526
|
+
),
|
|
527
|
+
)
|
|
528
|
+
: new Set<string>();
|
|
529
|
+
const messages = feed.messages.filter((message) => !held.has(message.id));
|
|
530
|
+
if (messages.length === 0) {
|
|
495
531
|
continue;
|
|
496
532
|
}
|
|
497
533
|
|
|
498
|
-
const namespace =
|
|
499
|
-
feed.namespace === 'trace' ? FeedProtocol.WellKnownNamespaces.trace : FeedProtocol.WellKnownNamespaces.data;
|
|
500
534
|
try {
|
|
501
535
|
// Handlers are served, not called directly; bridge to a client in-process for this write.
|
|
502
536
|
const feedService = this._echoHost.feedService;
|
|
@@ -507,11 +541,15 @@ export class SpacesServiceImpl implements SpacesService.Handlers {
|
|
|
507
541
|
spaceId: space.id,
|
|
508
542
|
feedId: feed.feedObjectId,
|
|
509
543
|
subspaceTag: namespace,
|
|
510
|
-
objects:
|
|
544
|
+
objects: messages.map((message) => JSON.stringify(message)),
|
|
511
545
|
});
|
|
512
546
|
}).pipe(Effect.scoped),
|
|
513
547
|
);
|
|
514
548
|
} catch (err) {
|
|
549
|
+
// A merge into an existing space must fail rather than report success with messages missing.
|
|
550
|
+
if (merge) {
|
|
551
|
+
throw err;
|
|
552
|
+
}
|
|
515
553
|
log.warn('failed to import feed data', { feedObjectId: feed.feedObjectId, error: err });
|
|
516
554
|
}
|
|
517
555
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as Deferred from 'effect/Deferred';
|
|
6
|
+
import * as Effect from 'effect/Effect';
|
|
7
|
+
import * as Layer from 'effect/Layer';
|
|
8
|
+
import * as Option from 'effect/Option';
|
|
9
|
+
import * as SqlClient from 'effect/sql/SqlClient';
|
|
10
|
+
import { describe, test } from 'vitest';
|
|
11
|
+
|
|
12
|
+
import * as Hook from '@dxos/effect/Hook';
|
|
13
|
+
import { layerMemory } from '@dxos/sql-sqlite/Platform';
|
|
14
|
+
|
|
15
|
+
import * as Events from '../../Events.ts';
|
|
16
|
+
import { storageWatchdogLayer } from './storage-watchdog.ts';
|
|
17
|
+
|
|
18
|
+
/** Opens a stack watched by the watchdog, optionally with a transaction holding the connection, and reports whether it was declared stuck. */
|
|
19
|
+
const reportsStuck = ({ hold }: { hold: boolean }) =>
|
|
20
|
+
Effect.gen(function* () {
|
|
21
|
+
const stuck = yield* Deferred.make<void>();
|
|
22
|
+
yield* Hook.on(Events.StorageStuck, () => Deferred.succeed(stuck, undefined));
|
|
23
|
+
yield* Layer.build(storageWatchdogLayer({ interval: '10 millis', stuckAfter: '50 millis' }));
|
|
24
|
+
if (hold) {
|
|
25
|
+
const sql = yield* SqlClient.SqlClient;
|
|
26
|
+
yield* Effect.forkScoped(sql.withTransaction(Effect.never));
|
|
27
|
+
}
|
|
28
|
+
yield* Hook.emit(Events.StackOpened, undefined);
|
|
29
|
+
return Option.isSome(yield* Deferred.await(stuck).pipe(Effect.timeoutOption('500 millis')));
|
|
30
|
+
}).pipe(
|
|
31
|
+
Effect.scoped,
|
|
32
|
+
Effect.provide(layerMemory),
|
|
33
|
+
Effect.provideService(Hook.Controller, Hook.makeController()),
|
|
34
|
+
Effect.orDie,
|
|
35
|
+
Effect.runPromise,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
describe('storageWatchdogLayer', () => {
|
|
39
|
+
test('a connection held past the limit is reported stuck', async ({ expect }) => {
|
|
40
|
+
expect(await reportsStuck({ hold: true })).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('a free connection is not', async ({ expect }) => {
|
|
44
|
+
expect(await reportsStuck({ hold: false })).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as Duration from 'effect/Duration';
|
|
6
|
+
import * as Effect from 'effect/Effect';
|
|
7
|
+
import * as Layer from 'effect/Layer';
|
|
8
|
+
import * as Option from 'effect/Option';
|
|
9
|
+
import * as SqlClient from 'effect/sql/SqlClient';
|
|
10
|
+
|
|
11
|
+
import * as Hook from '@dxos/effect/Hook';
|
|
12
|
+
import { log } from '@dxos/log';
|
|
13
|
+
|
|
14
|
+
import * as Events from '../../Events.ts';
|
|
15
|
+
|
|
16
|
+
export type StorageWatchdogOptions = {
|
|
17
|
+
/** How often the connection is asked for. */
|
|
18
|
+
interval?: Duration.Input;
|
|
19
|
+
/** How long a probe may wait for the connection before storage counts as stuck: above any one statement a healthy store runs. */
|
|
20
|
+
stuckAfter?: Duration.Input;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Once the stack is open, asks for the SQLite connection on an interval. Every statement and index pass
|
|
25
|
+
* goes through that one connection, so a probe that cannot get it means nothing more will be saved:
|
|
26
|
+
* {@link Events.StorageStuck} is emitted once, for the embedder to replace the process.
|
|
27
|
+
*/
|
|
28
|
+
export const storageWatchdogLayer = ({
|
|
29
|
+
interval = Duration.seconds(30),
|
|
30
|
+
stuckAfter = Duration.minutes(2),
|
|
31
|
+
}: StorageWatchdogOptions = {}) =>
|
|
32
|
+
Layer.effectDiscard(
|
|
33
|
+
Effect.gen(function* () {
|
|
34
|
+
const sql = yield* SqlClient.SqlClient;
|
|
35
|
+
const controller = yield* Hook.Controller;
|
|
36
|
+
const scope = yield* Effect.scope;
|
|
37
|
+
|
|
38
|
+
const watch = Effect.gen(function* () {
|
|
39
|
+
while (true) {
|
|
40
|
+
yield* Effect.sleep(interval);
|
|
41
|
+
const startedAt = Date.now();
|
|
42
|
+
// A failing statement still got the connection; only a probe that never runs is stuck.
|
|
43
|
+
const answered = yield* sql`SELECT 1`.pipe(
|
|
44
|
+
Effect.timeoutOption(stuckAfter),
|
|
45
|
+
Effect.catch(() => Effect.succeed(Option.some([]))),
|
|
46
|
+
);
|
|
47
|
+
if (Option.isNone(answered)) {
|
|
48
|
+
log.error('sqlite connection stuck, requesting a new process', { waitedMs: Date.now() - startedAt });
|
|
49
|
+
yield* Hook.emit(Events.StorageStuck, undefined);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}).pipe(Effect.provideService(Hook.Controller, controller));
|
|
54
|
+
|
|
55
|
+
// Forked: `emit` waits on its handlers, and this one runs for the life of the stack.
|
|
56
|
+
yield* Hook.on(Events.StackOpened, () => Effect.asVoid(Effect.forkIn(watch, scope)));
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
@@ -168,6 +168,8 @@ export const makeWorkerRuntime = ({
|
|
|
168
168
|
SqliteStorage.wipeSqliteStorage.pipe(Effect.provide(sqlite), Effect.orDie),
|
|
169
169
|
);
|
|
170
170
|
yield* Hook.on(Events.Reset, () => requestShutdown);
|
|
171
|
+
// The tabs hold every change this process has not saved, and reconnect to a replacement with them.
|
|
172
|
+
yield* Hook.on(Events.StorageStuck, () => requestShutdown);
|
|
171
173
|
yield* Hook.on(
|
|
172
174
|
SessionClosed,
|
|
173
175
|
Effect.fn('WorkerRuntime.onSessionClosed')(function* ({ session }) {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
--
|
|
2
|
+
-- Device-local, content-addressed blob bytes, kept outside Automerge so large blobs do not bloat
|
|
3
|
+
-- documents. `uploaded_at` is NULL while the hosted store does not yet hold the bytes; the partial
|
|
4
|
+
-- index makes that upload ledger cheap to scan.
|
|
5
|
+
--
|
|
6
|
+
-- Immutable: recorded in `blobs_migrations` and never re-run.
|
|
7
|
+
--
|
|
8
|
+
CREATE TABLE IF NOT EXISTS blobs (
|
|
9
|
+
hash TEXT PRIMARY KEY,
|
|
10
|
+
type TEXT,
|
|
11
|
+
size INTEGER NOT NULL,
|
|
12
|
+
data BLOB NOT NULL,
|
|
13
|
+
created_at INTEGER NOT NULL,
|
|
14
|
+
uploaded_at INTEGER
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
CREATE INDEX IF NOT EXISTS blobs_pending ON blobs (created_at) WHERE uploaded_at IS NULL;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as SqlMigrations from '@dxos/sql-sqlite/SqlMigrations';
|
|
6
|
+
|
|
7
|
+
import init from './0001_init.sql?raw';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Local blob store migrations, keyed `<id>_<name>` as `Migrator.fromRecord` expects. Ids must only ever increase,
|
|
11
|
+
* and an applied migration must never be edited — nothing verifies its contents.
|
|
12
|
+
*/
|
|
13
|
+
export const MIGRATIONS = {
|
|
14
|
+
'0001_init': SqlMigrations.apply(init),
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Own history table per store, since many stores share the client database. */
|
|
18
|
+
export const MIGRATIONS_TABLE = 'blobs_migrations';
|
|
@@ -11,12 +11,15 @@ import * as EffectEx from '@dxos/effect/EffectEx';
|
|
|
11
11
|
import { layerMemory } from '@dxos/sql-sqlite/Platform';
|
|
12
12
|
import * as SqlMigrations from '@dxos/sql-sqlite/SqlMigrations';
|
|
13
13
|
|
|
14
|
+
import blobsInit from './blobs/0001_init.sql?raw';
|
|
15
|
+
import { MIGRATIONS as BLOBS, MIGRATIONS_TABLE as BLOBS_TABLE } from './blobs/index.ts';
|
|
14
16
|
import hypercoreInit from './hypercore/0001_init.sql?raw';
|
|
15
17
|
import { MIGRATIONS as HYPERCORE, MIGRATIONS_TABLE as HYPERCORE_TABLE } from './hypercore/index.ts';
|
|
16
18
|
import metadataInit from './metadata/0001_init.sql?raw';
|
|
17
19
|
import { MIGRATIONS as METADATA, MIGRATIONS_TABLE as METADATA_TABLE } from './metadata/index.ts';
|
|
18
20
|
|
|
19
21
|
const STORES = [
|
|
22
|
+
{ name: 'blobs', init: blobsInit, manifest: BLOBS, table: BLOBS_TABLE },
|
|
20
23
|
{ name: 'hypercore', init: hypercoreInit, manifest: HYPERCORE, table: HYPERCORE_TABLE },
|
|
21
24
|
{ name: 'metadata', init: metadataInit, manifest: METADATA, table: METADATA_TABLE },
|
|
22
25
|
];
|
package/src/version.ts
CHANGED