@dxos/cli-util 0.8.4-main.fcfe5033a5 → 0.8.4-staging.60fe92afc8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/cli-util",
3
- "version": "0.8.4-main.fcfe5033a5",
3
+ "version": "0.8.4-staging.60fe92afc8",
4
4
  "description": "Shared CLI utilities for DXOS CLI commands and plugins",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -8,7 +8,7 @@
8
8
  "type": "git",
9
9
  "url": "https://github.com/dxos/dxos"
10
10
  },
11
- "license": "MIT",
11
+ "license": "FSL-1.1-Apache-2.0",
12
12
  "author": "DXOS.org",
13
13
  "sideEffects": false,
14
14
  "type": "module",
@@ -30,27 +30,29 @@
30
30
  "src"
31
31
  ],
32
32
  "dependencies": {
33
- "@effect/cli": "0.73.2",
34
- "@effect/printer": "0.47.0",
35
- "@effect/printer-ansi": "0.47.0",
36
- "@dxos/app-toolkit": "0.8.4-main.fcfe5033a5",
37
- "@dxos/echo": "0.8.4-main.fcfe5033a5",
38
- "@dxos/debug": "0.8.4-main.fcfe5033a5",
39
- "@dxos/client": "0.8.4-main.fcfe5033a5",
40
- "@dxos/effect": "0.8.4-main.fcfe5033a5",
41
- "@dxos/functions": "0.8.4-main.fcfe5033a5",
42
- "@dxos/errors": "0.8.4-main.fcfe5033a5",
43
- "@dxos/log": "0.8.4-main.fcfe5033a5",
44
- "@dxos/protocols": "0.8.4-main.fcfe5033a5",
45
- "@dxos/util": "0.8.4-main.fcfe5033a5"
33
+ "@effect/cli": "0.75.2",
34
+ "@effect/printer": "0.49.0",
35
+ "@effect/printer-ansi": "0.49.0",
36
+ "@dxos/app-toolkit": "0.8.4-staging.60fe92afc8",
37
+ "@dxos/client": "0.8.4-staging.60fe92afc8",
38
+ "@dxos/compute": "0.8.4-staging.60fe92afc8",
39
+ "@dxos/debug": "0.8.4-staging.60fe92afc8",
40
+ "@dxos/echo-client": "0.8.4-staging.60fe92afc8",
41
+ "@dxos/effect": "0.8.4-staging.60fe92afc8",
42
+ "@dxos/echo": "0.8.4-staging.60fe92afc8",
43
+ "@dxos/errors": "0.8.4-staging.60fe92afc8",
44
+ "@dxos/log": "0.8.4-staging.60fe92afc8",
45
+ "@dxos/functions": "0.8.4-staging.60fe92afc8",
46
+ "@dxos/protocols": "0.8.4-staging.60fe92afc8",
47
+ "@dxos/util": "0.8.4-staging.60fe92afc8"
46
48
  },
47
49
  "devDependencies": {
48
- "effect": "3.20.0",
50
+ "effect": "3.21.3",
49
51
  "typescript": "^6.0.3",
50
- "vitest": "3.2.4"
52
+ "vitest": "4.1.7"
51
53
  },
52
54
  "peerDependencies": {
53
- "effect": "3.20.0"
55
+ "effect": "3.21.3"
54
56
  },
55
57
  "publishConfig": {
56
58
  "access": "public"
@@ -8,11 +8,11 @@ import * as Effect from 'effect/Effect';
8
8
  import * as Option from 'effect/Option';
9
9
 
10
10
  import { FormBuilder, print } from '@dxos/cli-util';
11
- import { runAndForwardErrors } from '@dxos/effect';
11
+ import { EffectEx } from '@dxos/effect';
12
12
 
13
13
  describe('FormBuilder', () => {
14
14
  it('option', () =>
15
- runAndForwardErrors(
15
+ EffectEx.runAndForwardErrors(
16
16
  Effect.gen(function* () {
17
17
  const builder = FormBuilder.make({ title: 'Option Test' }).pipe(
18
18
  FormBuilder.option('some', Option.some('value')),
@@ -25,7 +25,7 @@ describe('FormBuilder', () => {
25
25
  ));
26
26
 
27
27
  it('when', () =>
28
- runAndForwardErrors(
28
+ EffectEx.runAndForwardErrors(
29
29
  Effect.gen(function* () {
30
30
  const builder = FormBuilder.make({ title: 'When Test' }).pipe(
31
31
  FormBuilder.when(true, FormBuilder.set('included', 'true')),
@@ -38,7 +38,7 @@ describe('FormBuilder', () => {
38
38
  ));
39
39
 
40
40
  it('each', () =>
41
- runAndForwardErrors(
41
+ EffectEx.runAndForwardErrors(
42
42
  Effect.gen(function* () {
43
43
  const items = ['a', 'b', 'c'];
44
44
  const builder = FormBuilder.make({ title: 'Each Test' }).pipe(
@@ -51,7 +51,7 @@ describe('FormBuilder', () => {
51
51
  ));
52
52
 
53
53
  it('build', () =>
54
- runAndForwardErrors(
54
+ EffectEx.runAndForwardErrors(
55
55
  Effect.gen(function* () {
56
56
  const builder = FormBuilder.make({ title: 'Test' }).pipe(
57
57
  FormBuilder.set('foo', 100),
@@ -64,7 +64,7 @@ describe('FormBuilder', () => {
64
64
  ));
65
65
 
66
66
  it('nest', () =>
67
- runAndForwardErrors(
67
+ EffectEx.runAndForwardErrors(
68
68
  Effect.gen(function* () {
69
69
  const nested = FormBuilder.make({ title: 'Nested' }).pipe(
70
70
  FormBuilder.set('nested1', 'value1'),
@@ -83,7 +83,7 @@ describe('FormBuilder', () => {
83
83
  ));
84
84
 
85
85
  it('nestedOption', () =>
86
- runAndForwardErrors(
86
+ EffectEx.runAndForwardErrors(
87
87
  Effect.gen(function* () {
88
88
  const nested = FormBuilder.make({ title: 'Nested' }).pipe(FormBuilder.set('key', 'value'));
89
89
 
@@ -98,7 +98,7 @@ describe('FormBuilder', () => {
98
98
  ));
99
99
 
100
100
  it('multi-level nesting', () =>
101
- runAndForwardErrors(
101
+ EffectEx.runAndForwardErrors(
102
102
  Effect.gen(function* () {
103
103
  const grandchild = FormBuilder.make({ title: 'Grandchild' }).pipe(
104
104
  FormBuilder.set('grandchild1', 'value1'),
@@ -123,7 +123,7 @@ describe('FormBuilder', () => {
123
123
  ));
124
124
 
125
125
  it('pipeable build', () =>
126
- runAndForwardErrors(
126
+ EffectEx.runAndForwardErrors(
127
127
  Effect.gen(function* () {
128
128
  const doc = FormBuilder.make({ title: 'Pipeable' }).pipe(
129
129
  FormBuilder.set('foo', 100),
@@ -135,7 +135,7 @@ describe('FormBuilder', () => {
135
135
  ));
136
136
 
137
137
  it('dual calling styles', () =>
138
- runAndForwardErrors(
138
+ EffectEx.runAndForwardErrors(
139
139
  Effect.gen(function* () {
140
140
  // Curried style (pipe)
141
141
  const builder1 = FormBuilder.make({ title: 'Curried' }).pipe(
@@ -2,40 +2,114 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
+ import * as Duration from 'effect/Duration';
5
6
  import * as Effect from 'effect/Effect';
6
7
 
7
8
  import { type Space, SpaceState, type SpaceSyncState } from '@dxos/client/echo';
8
9
 
9
10
  import * as FormBuilder from './form-builder';
10
11
 
12
+ export type FormatSpaceOptions = {
13
+ verbose?: boolean;
14
+ truncateKeys?: boolean;
15
+ /**
16
+ * If set, wait up to this many seconds for the space to reach
17
+ * `SPACE_READY` before reading its fields. If unset, read whatever state
18
+ * is available right now — much safer for `space list` etc., where a
19
+ * single stuck space would otherwise hang the entire command.
20
+ */
21
+ waitSeconds?: number;
22
+ };
23
+
24
+ const DEFAULT_OPTIONS: Required<FormatSpaceOptions> = {
25
+ verbose: false,
26
+ truncateKeys: false,
27
+ waitSeconds: 0,
28
+ };
29
+
30
+ /**
31
+ * Per-async-read internal timeout. Some `space.internal.*` getters do
32
+ * filesystem / network IO and can themselves hang on a partially-loaded
33
+ * space; cap each one so the command can never be held hostage by SDK
34
+ * internals.
35
+ */
36
+ const READ_TIMEOUT_SECONDS = 2;
37
+
38
+ const tryWithFallback = <T>(label: string, run: () => Promise<T>, fallback: T) =>
39
+ Effect.tryPromise(run).pipe(
40
+ Effect.timeoutFail({
41
+ duration: Duration.seconds(READ_TIMEOUT_SECONDS),
42
+ onTimeout: () => new Error(`${label} timed out`),
43
+ }),
44
+ Effect.catchAll(() => Effect.succeed(fallback)),
45
+ );
46
+
47
+ const tryWithFallbackSync = <T>(read: () => T, fallback: T): T => {
48
+ try {
49
+ return read();
50
+ } catch {
51
+ return fallback;
52
+ }
53
+ };
54
+
11
55
  // TODO(wittjosiah): Use @effect/printer.
12
- export const formatSpace = Effect.fn(function* (space: Space, options = { verbose: false, truncateKeys: false }) {
13
- yield* Effect.tryPromise(() => space.waitUntilReady());
56
+ export const formatSpace = Effect.fn(function* (space: Space, options: FormatSpaceOptions = {}) {
57
+ const { waitSeconds } = { ...DEFAULT_OPTIONS, ...options };
58
+
59
+ // Opt-in wait. Defaults to NO wait so a single stuck space can't hang
60
+ // an enumeration command (e.g. `dx space list`).
61
+ if (waitSeconds > 0) {
62
+ yield* Effect.tryPromise(() => space.waitUntilReady()).pipe(
63
+ Effect.timeoutFail({
64
+ duration: Duration.seconds(waitSeconds),
65
+ onTimeout: () => new Error('waitUntilReady timed out'),
66
+ }),
67
+ Effect.catchAll(() => Effect.void),
68
+ );
69
+ }
70
+
71
+ const state = tryWithFallbackSync(() => space.state.get(), SpaceState.SPACE_INITIALIZING);
72
+ const ready = state === SpaceState.SPACE_READY;
14
73
 
15
74
  // TODO(burdon): Factor out.
16
75
  // TODO(burdon): Agent needs to restart before `ready` is available.
17
- const { open, ready } = space.internal.data.metrics ?? {};
18
- const startup = open && ready && ready.getTime() - open.getTime();
76
+ const metrics = tryWithFallbackSync(
77
+ () => space.internal.data.metrics,
78
+ undefined as { open?: Date; ready?: Date } | undefined,
79
+ );
80
+ const startup = metrics?.open && metrics?.ready ? metrics.ready.getTime() - metrics.open.getTime() : undefined;
19
81
 
20
82
  // TODO(burdon): Get feeds from client-services if verbose (factor out from devtools/diagnostics).
21
83
  // const host = client.services.services.DevtoolsHost!;
22
- const pipeline = space.internal.data.pipeline;
84
+ const pipeline = tryWithFallbackSync(() => space.internal.data.pipeline, undefined);
23
85
  const epoch = pipeline?.currentEpoch?.subject.assertion.number;
24
86
 
25
- const syncState = aggregateSyncState(yield* Effect.tryPromise(() => space.internal.db.coreDatabase.getSyncState()));
87
+ // The sync-state read does IO; cap it so a stuck space can't hang the
88
+ // command. Falls back to a "no peers" placeholder.
89
+ const syncStateRaw = yield* tryWithFallback('getSyncState', () => space.internal.db.getSyncState(), {
90
+ peers: {},
91
+ } as SpaceSyncState);
92
+ const syncState = aggregateSyncState(syncStateRaw);
93
+
94
+ const name = ready ? tryWithFallbackSync(() => space.properties.name, undefined) : 'loading...';
95
+ const members = tryWithFallbackSync(() => space.members.get().length, 0);
96
+ const objects = tryWithFallbackSync(() => space.internal.db.getAllObjectIds().length, 0);
97
+ const key = options.truncateKeys
98
+ ? tryWithFallbackSync(() => space.key.truncate(), '')
99
+ : tryWithFallbackSync(() => space.key.toHex(), '');
26
100
 
27
101
  return {
28
102
  id: space.id,
29
- state: SpaceState[space.state.get()],
30
- name: space.state.get() === SpaceState.SPACE_READY ? space.properties.name : 'loading...',
103
+ state: SpaceState[state],
104
+ name,
31
105
 
32
- members: space.members.get().length,
33
- objects: space.internal.db.coreDatabase.getAllObjectIds().length,
106
+ members,
107
+ objects,
34
108
 
35
- key: options.truncateKeys ? space.key.truncate() : space.key.toHex(),
109
+ key,
36
110
  epoch,
37
111
  startup,
38
- automergeRoot: space.internal.data.pipeline?.spaceRootUrl,
112
+ automergeRoot: pipeline?.spaceRootUrl,
39
113
  // appliedEpoch,
40
114
  syncState: `${syncState.count} ${getSyncIndicator(syncState.up, syncState.down)} (${syncState.peers} peers)`,
41
115
  };
package/src/util/space.ts CHANGED
@@ -5,15 +5,14 @@
5
5
  import * as Console from 'effect/Console';
6
6
  import * as Effect from 'effect/Effect';
7
7
  import * as Layer from 'effect/Layer';
8
- import * as Match from 'effect/Match';
9
8
  import * as Option from 'effect/Option';
10
9
 
11
10
  import { getPersonalSpace } from '@dxos/app-toolkit';
12
11
  import { ClientService } from '@dxos/client';
13
12
  import { type Space } from '@dxos/client/echo';
14
- import { Database, type Key } from '@dxos/echo';
13
+ import { Database, Feed, type Key } from '@dxos/echo';
14
+ import { createFeedServiceLayer } from '@dxos/echo-client';
15
15
  import { BaseError, type BaseErrorOptions } from '@dxos/errors';
16
- import { QueueService } from '@dxos/functions';
17
16
  import { log } from '@dxos/log';
18
17
  import { EdgeReplicationSetting } from '@dxos/protocols/proto/dxos/echo/metadata';
19
18
  import { isBun } from '@dxos/util';
@@ -40,20 +39,29 @@ export const spaceIdWithDefault = (spaceId: Option.Option<Key.SpaceId>) =>
40
39
  export const spaceLayer = (
41
40
  spaceId$: Option.Option<Key.SpaceId>,
42
41
  fallbackToPersonalSpace = false,
43
- ): Layer.Layer<Database.Service | QueueService, never, ClientService> => {
42
+ ): Layer.Layer<Database.Service | Feed.FeedService, never, ClientService> => {
44
43
  const getSpace = Effect.fn(function* () {
45
44
  const client = yield* ClientService;
46
45
 
47
- const spaceId = Match.value(fallbackToPersonalSpace).pipe(
48
- Match.when(true, () => spaceId$.pipe(Option.orElse(() => Option.fromNullable(getPersonalSpace(client)?.id)))),
49
- Match.when(false, () => spaceId$),
50
- Match.exhaustive,
51
- );
46
+ // Resolution order when fallbackToPersonalSpace is true:
47
+ // 1. the explicit spaceId arg (if provided);
48
+ // 2. the space tagged `org.dxos.space.personal`;
49
+ // 3. the first available space.
50
+ // This keeps profiles created outside composer-app (which is what creates
51
+ // the personal-space tag on identity creation) usable — the alternative
52
+ // is a "Space not found" throw deep inside CredentialsService.
53
+ const resolveSpace = () => {
54
+ if (!fallbackToPersonalSpace) {
55
+ return spaceId$.pipe(Option.flatMap((id) => Option.fromNullable(client.spaces.get(id))));
56
+ }
57
+ return spaceId$.pipe(
58
+ Option.flatMap((id) => Option.fromNullable(client.spaces.get(id))),
59
+ Option.orElse(() => Option.fromNullable(getPersonalSpace(client))),
60
+ Option.orElse(() => Option.fromNullable(client.spaces.get()[0])),
61
+ );
62
+ };
52
63
 
53
- const space = spaceId.pipe(
54
- Option.flatMap((id) => Option.fromNullable(client.spaces.get(id))),
55
- Option.getOrUndefined,
56
- );
64
+ const space = resolveSpace().pipe(Option.getOrUndefined);
57
65
 
58
66
  if (space) {
59
67
  yield* Effect.promise(() => space.waitUntilReady());
@@ -61,49 +69,41 @@ export const spaceLayer = (
61
69
  return space;
62
70
  });
63
71
 
72
+ // When no space can be resolved we install a stub whose `db` getter throws
73
+ // on access — preserves the existing semantics for commands that *do* need
74
+ // a db — but the release callback must NOT touch `db` or it will throw
75
+ // during teardown (e.g. after a command emits a friendly error and
76
+ // returns early). A shared sentinel object short-circuits the release.
77
+ const NO_DB_STUB = {
78
+ get db(): Database.Database {
79
+ throw new Error('Space not found');
80
+ },
81
+ };
64
82
  const db = Layer.scoped(
65
83
  Database.Service,
66
84
  Effect.acquireRelease(
67
85
  Effect.gen(function* () {
68
86
  const space = yield* getSpace();
69
87
  if (!space) {
70
- return {
71
- get db(): Database.Database {
72
- throw new Error('Space not found');
73
- },
74
- };
88
+ return NO_DB_STUB;
75
89
  }
76
90
  return { db: space.db };
77
91
  }),
78
- ({ db }) => Effect.promise(() => db.flush()),
92
+ (holder) => (holder === NO_DB_STUB ? Effect.void : Effect.promise(() => holder.db.flush())),
79
93
  ),
80
94
  );
81
95
 
82
- const queue = Layer.effect(
83
- QueueService,
96
+ const feed = Layer.unwrapEffect(
84
97
  Effect.gen(function* () {
85
98
  const space = yield* getSpace();
86
99
  if (!space) {
87
- return {
88
- queues: {
89
- get: (_dxn) => {
90
- throw new Error('Queues not available');
91
- },
92
- create: () => {
93
- throw new Error('Queues not available');
94
- },
95
- },
96
- queue: undefined,
97
- };
100
+ return Feed.notAvailable;
98
101
  }
99
- return {
100
- queues: space.queues,
101
- queue: undefined,
102
- };
102
+ return createFeedServiceLayer(space.queues);
103
103
  }),
104
104
  );
105
105
 
106
- return Layer.merge(db, queue);
106
+ return Layer.merge(db, feed);
107
107
  };
108
108
 
109
109
  // TODO(dmaretskyi): There a race condition with edge connection not showing up.