@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.
Files changed (57) hide show
  1. package/dist/lib/chunk-client-services-stack.mjs +799 -573
  2. package/dist/lib/chunk-client-services-stack.mjs.map +1 -1
  3. package/dist/lib/index.mjs +139 -131
  4. package/dist/lib/index.mjs.map +1 -1
  5. package/dist/lib/testing.mjs +1 -1
  6. package/dist/lib/testing.mjs.map +1 -1
  7. package/dist/types/src/Events.d.ts +2 -0
  8. package/dist/types/src/Events.d.ts.map +1 -1
  9. package/dist/types/src/internal/blobs/blob-replication.d.ts +28 -0
  10. package/dist/types/src/internal/blobs/blob-replication.d.ts.map +1 -0
  11. package/dist/types/src/internal/blobs/blob-store-service.d.ts +10 -0
  12. package/dist/types/src/internal/blobs/blob-store-service.d.ts.map +1 -0
  13. package/dist/types/src/internal/blobs/blob-store-service.test.d.ts +2 -0
  14. package/dist/types/src/internal/blobs/blob-store-service.test.d.ts.map +1 -0
  15. package/dist/types/src/internal/blobs/index.d.ts +4 -0
  16. package/dist/types/src/internal/blobs/index.d.ts.map +1 -0
  17. package/dist/types/src/internal/blobs/local-blob-store.d.ts +21 -0
  18. package/dist/types/src/internal/blobs/local-blob-store.d.ts.map +1 -0
  19. package/dist/types/src/internal/blobs/local-blob-store.test.d.ts +2 -0
  20. package/dist/types/src/internal/blobs/local-blob-store.test.d.ts.map +1 -0
  21. package/dist/types/src/internal/services/layer-specs.d.ts +6 -0
  22. package/dist/types/src/internal/services/layer-specs.d.ts.map +1 -1
  23. package/dist/types/src/internal/services/service-stack.d.ts.map +1 -1
  24. package/dist/types/src/internal/space-export/serialized-space-writer.d.ts +4 -1
  25. package/dist/types/src/internal/space-export/serialized-space-writer.d.ts.map +1 -1
  26. package/dist/types/src/internal/spaces/spaces-service.d.ts.map +1 -1
  27. package/dist/types/src/internal/storage/index.d.ts +1 -0
  28. package/dist/types/src/internal/storage/index.d.ts.map +1 -1
  29. package/dist/types/src/internal/storage/storage-watchdog.d.ts +17 -0
  30. package/dist/types/src/internal/storage/storage-watchdog.d.ts.map +1 -0
  31. package/dist/types/src/internal/storage/storage-watchdog.test.d.ts +2 -0
  32. package/dist/types/src/internal/storage/storage-watchdog.test.d.ts.map +1 -0
  33. package/dist/types/src/internal/worker/worker-runtime.d.ts.map +1 -1
  34. package/dist/types/src/migrations/blobs/index.d.ts +10 -0
  35. package/dist/types/src/migrations/blobs/index.d.ts.map +1 -0
  36. package/dist/types/src/version.d.ts +1 -1
  37. package/dist/types/tsconfig.tsbuildinfo +1 -1
  38. package/package.json +43 -42
  39. package/src/Events.ts +3 -0
  40. package/src/internal/blobs/blob-replication.ts +80 -0
  41. package/src/internal/blobs/blob-store-service.test.ts +76 -0
  42. package/src/internal/blobs/blob-store-service.ts +45 -0
  43. package/src/internal/blobs/index.ts +7 -0
  44. package/src/internal/blobs/local-blob-store.test.ts +53 -0
  45. package/src/internal/blobs/local-blob-store.ts +89 -0
  46. package/src/internal/services/layer-specs.ts +55 -0
  47. package/src/internal/services/service-stack.ts +2 -0
  48. package/src/internal/space-export/serialized-space-writer.ts +16 -9
  49. package/src/internal/spaces/spaces-service.ts +44 -6
  50. package/src/internal/storage/index.ts +1 -0
  51. package/src/internal/storage/storage-watchdog.test.ts +46 -0
  52. package/src/internal/storage/storage-watchdog.ts +58 -0
  53. package/src/internal/worker/worker-runtime.ts +2 -0
  54. package/src/migrations/blobs/0001_init.sql +17 -0
  55. package/src/migrations/blobs/index.ts +18 -0
  56. package/src/migrations/migrations.test.ts +3 -0
  57. 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
- if (format === SpacesService.SpaceArchiveFormat.enums.JSON) {
399
- const archive = await writeSerializedSpaceArchive({ space, echoHost: this._echoHost });
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
- if (feed.messages.length === 0) {
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: feed.messages.map((message) => JSON.stringify(message)),
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
  }
@@ -5,3 +5,4 @@
5
5
  export * from './storage.ts';
6
6
  export * from './profile-archive.ts';
7
7
  export * from './profile-archive-sqlite.ts';
8
+ export * from './storage-watchdog.ts';
@@ -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
@@ -2,4 +2,4 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- export const DXOS_VERSION = '0.14.0';
5
+ export const DXOS_VERSION = '0.16.0';