@dxos/echo-protocol 0.10.0 → 0.11.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/echo-protocol",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Core ECHO APIs.",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -16,7 +16,7 @@
16
16
  ".": {
17
17
  "source": "./src/index.ts",
18
18
  "types": "./dist/types/src/index.d.ts",
19
- "default": "./dist/lib/neutral/index.mjs"
19
+ "import": "./dist/lib/index.mjs"
20
20
  }
21
21
  },
22
22
  "types": "dist/types/src/index.d.ts",
@@ -26,11 +26,11 @@
26
26
  ],
27
27
  "dependencies": {
28
28
  "effect": "3.21.4",
29
- "@dxos/crypto": "0.10.0",
30
- "@dxos/keys": "0.10.0",
31
- "@dxos/util": "0.10.0",
32
- "@dxos/invariant": "0.10.0",
33
- "@dxos/protocols": "0.10.0"
29
+ "@dxos/crypto": "0.11.0",
30
+ "@dxos/invariant": "0.11.0",
31
+ "@dxos/keys": "0.11.0",
32
+ "@dxos/protocols": "0.11.0",
33
+ "@dxos/util": "0.11.0"
34
34
  },
35
35
  "publishConfig": {
36
36
  "access": "public"
package/src/blob.ts ADDED
@@ -0,0 +1,36 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type SpaceId } from '@dxos/keys';
6
+
7
+ export interface BlobPutRequest {
8
+ spaceId: SpaceId;
9
+ data: Uint8Array;
10
+ contentType?: string;
11
+ /** Lowercase hex SHA-256 digest of `data`, computed by the manager. The backend does not verify it. */
12
+ contentHash: string;
13
+ /** For path-addressed extension backends. */
14
+ name?: string;
15
+ }
16
+
17
+ export interface BlobPutResponse {
18
+ /** URI locating the stored bytes; must use a scheme the backend resolves. */
19
+ uri: string;
20
+ }
21
+
22
+ /**
23
+ * Implemented by pluggable blob storage backends and registered on the Hypergraph via
24
+ * `registerBlobBackend`.
25
+ */
26
+ export interface BlobBackend {
27
+ /** URI schemes this backend resolves at read time. */
28
+ readonly schemes: readonly string[];
29
+ /** Largest `data.byteLength` this backend accepts, in bytes. `undefined` means unlimited. */
30
+ readonly maxSize?: number;
31
+ put(request: BlobPutRequest): Promise<BlobPutResponse>;
32
+ /** `undefined` means the URI was not found. Rejects on transport failure (e.g. offline). */
33
+ get(request: { spaceId: SpaceId; uri: string }): Promise<Uint8Array | undefined>;
34
+ has(request: { spaceId: SpaceId; uri: string }): Promise<boolean>;
35
+ getUrl?(request: { spaceId: SpaceId; uri: string; contentType?: string }): Promise<string | undefined>;
36
+ }
@@ -3,13 +3,14 @@
3
3
  //
4
4
 
5
5
  import { invariant } from '@dxos/invariant';
6
- import type { EntityId, URI } from '@dxos/keys';
6
+ import { type EntityId, PublicKey, SpaceId, type URI } from '@dxos/keys';
7
7
  import { visitValues } from '@dxos/util';
8
8
 
9
9
  import { type RawString } from './automerge';
10
10
  import type { ForeignKey } from './foreign-key';
11
11
  import { type EncodedReference, isEncodedReference } from './reference';
12
12
  import { type SpaceDocVersion } from './space-doc-version';
13
+ import { createIdFromSpaceKey } from './space-id';
13
14
 
14
15
  export type SpaceState = {
15
16
  // Url of the root automerge document.
@@ -29,7 +30,19 @@ export interface DatabaseDirectory {
29
30
  version?: SpaceDocVersion;
30
31
 
31
32
  access?: {
32
- spaceKey: string;
33
+ /**
34
+ * ID of the space that owns the document.
35
+ */
36
+ spaceId?: SpaceId;
37
+
38
+ /**
39
+ * @deprecated Use {@link spaceId}. Still written alongside `spaceId` so older clients
40
+ * (and code paths that need the space public key, which cannot be recovered from the id)
41
+ * keep working.
42
+ *
43
+ * Space key of the owning space in hex format without the 0x prefix.
44
+ */
45
+ spaceKey?: string;
33
46
  };
34
47
  /**
35
48
  * Objects inlined in the current document.
@@ -44,6 +57,21 @@ export interface DatabaseDirectory {
44
57
  [echoUri: string]: string | RawString;
45
58
  };
46
59
 
60
+ /**
61
+ * Per-object branch registry. Keyed by the subtree-root object id, then by branch name; each
62
+ * branch records the automerge doc url holding each subtree member at that branch.
63
+ *
64
+ * This is the single synced source of truth for branches: it is both the branch list/membership
65
+ * AND the set of branch documents the space must replicate (the host collects these urls in
66
+ * {@link getAllBranchDocUrls}). The client document loader does NOT treat these urls as object
67
+ * links, so branch docs never materialize as phantom objects. The implicit `'main'` branch is
68
+ * never listed here (it is the object's main doc via {@link links}).
69
+ *
70
+ * Which branch a device is currently viewing is NOT stored here — that is device-local,
71
+ * non-synced state.
72
+ */
73
+ branches?: SpaceBranchRegistry;
74
+
47
75
  /**
48
76
  * @deprecated
49
77
  * For backward compatibility.
@@ -51,8 +79,51 @@ export interface DatabaseDirectory {
51
79
  experimental_spaceKey?: string;
52
80
  }
53
81
 
82
+ /**
83
+ * @see DatabaseDirectory.branches
84
+ */
85
+ export type SpaceBranchRegistry = {
86
+ [rootObjectId: string]: {
87
+ [branchName: string]: BranchRecord;
88
+ };
89
+ };
90
+
91
+ export type BranchRecord = {
92
+ /** Subtree member object id -> automerge doc url holding that member on this branch. */
93
+ members: { [objectId: string]: string | RawString };
94
+ /**
95
+ * The root object's main-doc heads at fork time. Provenance only — currently written but never
96
+ * read; the merge relies on shared automerge ancestry, not this field.
97
+ */
98
+ baseHeads?: string[];
99
+ /** Unix ms timestamp at branch creation. */
100
+ createdAt?: number;
101
+ };
102
+
54
103
  export const DatabaseDirectory = Object.freeze({
55
104
  /**
105
+ * @returns ID of the space that owns the document.
106
+ * Coalesces `access.spaceId` with the deprecated space key fields (`access.spaceKey`,
107
+ * `experimental_spaceKey`), deriving the id from the key for documents that predate `spaceId`.
108
+ */
109
+ getSpaceId: async (doc: DatabaseDirectory): Promise<SpaceId | null> => {
110
+ if (doc.access?.spaceId != null) {
111
+ invariant(SpaceId.isValid(doc.access.spaceId), 'Invalid space ID');
112
+ return doc.access.spaceId;
113
+ }
114
+
115
+ const spaceKeyHex = DatabaseDirectory.getSpaceKey(doc);
116
+ if (spaceKeyHex == null) {
117
+ return null;
118
+ }
119
+
120
+ return createIdFromSpaceKey(PublicKey.fromHex(spaceKeyHex));
121
+ },
122
+
123
+ /**
124
+ * @deprecated Use {@link DatabaseDirectory.getSpaceId}. Only paths that require the space
125
+ * public key (which cannot be derived from the space id) should read the key.
126
+ *
56
127
  * @returns Space key in hex of the space that owns the document. In hex format. Without 0x prefix.
57
128
  */
58
129
  getSpaceKey: (doc: DatabaseDirectory): string | null => {
@@ -75,17 +146,46 @@ export const DatabaseDirectory = Object.freeze({
75
146
  return doc.links?.[id]?.toString();
76
147
  },
77
148
 
149
+ /**
150
+ * @returns The branch registry for a subtree-root object, or undefined if it has no branches.
151
+ */
152
+ getBranches: (doc: DatabaseDirectory, rootObjectId: EntityId): Record<string, BranchRecord> | undefined => {
153
+ return doc.branches?.[rootObjectId];
154
+ },
155
+
156
+ /**
157
+ * @returns All branch document urls referenced anywhere in the registry. Used by the host to
158
+ * decide which documents to replicate (branch docs are NOT object links).
159
+ */
160
+ getAllBranchDocUrls: (doc: DatabaseDirectory): string[] => {
161
+ const urls: string[] = [];
162
+ for (const byName of Object.values(doc.branches ?? {})) {
163
+ for (const record of Object.values(byName)) {
164
+ for (const url of Object.values(record.members ?? {})) {
165
+ urls.push(url.toString());
166
+ }
167
+ }
168
+ }
169
+ return urls;
170
+ },
171
+
78
172
  make: ({
173
+ spaceId,
79
174
  spaceKey,
80
175
  objects,
81
176
  links,
82
177
  }: {
83
- spaceKey: string;
178
+ spaceId?: SpaceId;
179
+ /**
180
+ * @deprecated Provide {@link spaceId}. The key is still stamped for older clients.
181
+ */
182
+ spaceKey?: string;
84
183
  objects?: Record<string, EntityStructure>;
85
184
  links?: Record<string, RawString>;
86
185
  }): DatabaseDirectory => ({
87
186
  access: {
88
- spaceKey,
187
+ ...(spaceId != null ? { spaceId } : {}),
188
+ ...(spaceKey != null ? { spaceKey } : {}),
89
189
  },
90
190
  objects: objects ?? {},
91
191
  links: links ?? {},
@@ -18,10 +18,12 @@ export class EchoFeedCodec {
18
18
  static readonly #decoder = new TextDecoder();
19
19
 
20
20
  /**
21
- * Prepares a value for feed storage (strips queue position from metadata) and encodes to bytes.
21
+ * Feed blocks are always whole-object snapshots; the index collapses entries by id to the latest
22
+ * block. TODO(wittjosiah): Follow-up — a partial-object update block format with field-level
23
+ * last-write-wins merge at the index (see EntityMetaIndex.update).
22
24
  */
23
25
  static encode(value: Record<string, unknown>): Uint8Array {
24
- const prepared = EchoFeedCodec.#stripQueuePosition(value);
26
+ const prepared = EchoFeedCodec.stripQueuePosition(value);
25
27
  return EchoFeedCodec.#encoder.encode(JSON.stringify(prepared));
26
28
  }
27
29
 
@@ -37,7 +39,11 @@ export class EchoFeedCodec {
37
39
  return decoded;
38
40
  }
39
41
 
40
- static #stripQueuePosition(value: Record<string, unknown>): Record<string, unknown> {
42
+ /**
43
+ * Strips the queue-position foreign key from an object's metadata, producing a canonical form
44
+ * comparable across a local snapshot and an inbound feed block (positions differ per-append).
45
+ */
46
+ static stripQueuePosition(value: Record<string, unknown>): Record<string, unknown> {
41
47
  if (typeof value !== 'object' || value === null) {
42
48
  return value;
43
49
  }
@@ -65,3 +71,9 @@ export class EchoFeedCodec {
65
71
  });
66
72
  }
67
73
  }
74
+
75
+ /**
76
+ * Foreign-key source for the global position a feed block was assigned.
77
+ * Re-exported so `@dxos/echo` can read it without depending on `@dxos/protocols`.
78
+ */
79
+ export const KEY_QUEUE_POSITION = FeedProtocol.KEY_QUEUE_POSITION;
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
+ export * from './blob';
5
6
  export type * from './collection-sync';
6
7
  export * from './document-structure';
7
8
  export * from './edge-peer';
package/src/query/ast.ts CHANGED
@@ -80,6 +80,23 @@ const FilterIn_ = Schema.Struct({
80
80
  export interface FilterIn extends Schema.Schema.Type<typeof FilterIn_> {}
81
81
  export const FilterIn: Schema.Schema<FilterIn> = FilterIn_;
82
82
 
83
+ /**
84
+ * In (subquery form) — membership against a value projected from a subquery's results,
85
+ * e.g. `threadId IN (SELECT threadId FROM feed WHERE tag = 'inbox')`.
86
+ *
87
+ * Nested-only (like {@link FilterIn}): valid inside an `object` filter's `props`, not at
88
+ * the query root — the planner has no selector for a standalone membership predicate.
89
+ * The subquery may target a different scope than the parent query; it is resolved once at
90
+ * execution time by projecting `property` from its results into a set.
91
+ */
92
+ const FilterInQuery_ = Schema.Struct({
93
+ type: Schema.Literal('in-query'),
94
+ subquery: Schema.suspend(() => Query),
95
+ property: Schema.String,
96
+ });
97
+ export interface FilterInQuery extends Schema.Schema.Type<typeof FilterInQuery_> {}
98
+ export const FilterInQuery: Schema.Schema<FilterInQuery> = FilterInQuery_;
99
+
83
100
  /**
84
101
  * Contains.
85
102
  */
@@ -200,6 +217,7 @@ export const Filter = Schema.Union(
200
217
  FilterObject,
201
218
  FilterCompare,
202
219
  FilterIn,
220
+ FilterInQuery,
203
221
  FilterContains,
204
222
  FilterTag,
205
223
  FilterRange,
@@ -342,8 +360,10 @@ export type OrderDirection = Schema.Schema.Type<typeof OrderDirection>;
342
360
 
343
361
  const Order_ = Schema.Union(
344
362
  Schema.Struct({
345
- // How database wants to order them (in practice - by id).
363
+ // How the database wants to order them by default. For non-feed sources this is by id;
364
+ // for feed sources this is insertion order, so `desc` gives newest-first head reads.
346
365
  kind: Schema.Literal('natural'),
366
+ direction: OrderDirection,
347
367
  }),
348
368
  Schema.Struct({
349
369
  kind: Schema.Literal('property'),
@@ -404,6 +424,78 @@ const QueryLimitClause_ = Schema.Struct({
404
424
  export interface QueryLimitClause extends Schema.Schema.Type<typeof QueryLimitClause_> {}
405
425
  export const QueryLimitClause: Schema.Schema<QueryLimitClause> = QueryLimitClause_;
406
426
 
427
+ /**
428
+ * Skip a number of results (offset). Combined with `limit` and a `natural` order, this expresses
429
+ * a windowed (paginated) read without any feed-specific query surface.
430
+ */
431
+ const QuerySkipClause_ = Schema.Struct({
432
+ type: Schema.Literal('skip'),
433
+ query: Schema.suspend(() => Query),
434
+ skip: Schema.Number,
435
+ });
436
+
437
+ export interface QuerySkipClause extends Schema.Schema.Type<typeof QuerySkipClause_> {}
438
+ export const QuerySkipClause: Schema.Schema<QuerySkipClause> = QuerySkipClause_;
439
+
440
+ /**
441
+ * A named aggregate computed per group over its members, exposed as a top-level field on the flat
442
+ * result record (`row[name]`) and orderable via a following `orderBy(Order.property(name))`. A
443
+ * tagged union per kind — `property`/`limit`/`order` are present exactly when the kind uses them,
444
+ * so read sites narrow by `kind` instead of guarding an unused optional field.
445
+ * - `group` partitions members by a scalar `property`; its coerced key value is the field's value.
446
+ * Composite keys are formed from multiple `group` entries. A query with no `group` entries
447
+ * aggregates its entire input into a single row.
448
+ * - `max`/`min` reduce a scalar member `property`.
449
+ * - `items` collects the group's members, optionally ordered by `order` and capped to `limit`.
450
+ * Opt-in — a row carries no members otherwise. `order` is this aggregate's own per-group
451
+ * ordering, independent of any `orderBy` clause elsewhere in the query (which orders the whole
452
+ * input stream / the resulting groups, not this aggregate's member selection).
453
+ * - `count` yields the member count. Opt-in — a row carries no count otherwise.
454
+ */
455
+ const GroupAggregateGroup_ = Schema.Struct({
456
+ name: Schema.String,
457
+ kind: Schema.Literal('group'),
458
+ property: Schema.String,
459
+ });
460
+ const GroupAggregateMax_ = Schema.Struct({ name: Schema.String, kind: Schema.Literal('max'), property: Schema.String });
461
+ const GroupAggregateMin_ = Schema.Struct({ name: Schema.String, kind: Schema.Literal('min'), property: Schema.String });
462
+ const GroupAggregateItems_ = Schema.Struct({
463
+ name: Schema.String,
464
+ kind: Schema.Literal('items'),
465
+ limit: Schema.optional(Schema.Number),
466
+ order: Schema.optional(Schema.Array(Order)),
467
+ });
468
+ const GroupAggregateCount_ = Schema.Struct({ name: Schema.String, kind: Schema.Literal('count') });
469
+
470
+ const GroupAggregate_ = Schema.Union(
471
+ GroupAggregateGroup_,
472
+ GroupAggregateMax_,
473
+ GroupAggregateMin_,
474
+ GroupAggregateItems_,
475
+ GroupAggregateCount_,
476
+ );
477
+
478
+ export type GroupAggregate = Schema.Schema.Type<typeof GroupAggregate_>;
479
+ export const GroupAggregate: Schema.Schema<GroupAggregate> = GroupAggregate_;
480
+
481
+ /**
482
+ * Aggregates results into flat records. `group`-kind entries partition members into contiguous
483
+ * groups (one row each); with no `group` entries the whole input aggregates into a single row.
484
+ * Groups are ordered by the first occurrence of their key in the incoming (already-ordered) result
485
+ * stream — this lets a preceding `orderBy` also control group order (e.g. ordering thread groups by
486
+ * their most recent message). A following `orderBy(Order.property(name))` referencing an aggregate
487
+ * or group field reorders whole groups instead. Must be the outermost data clause: only
488
+ * `from`/`options`/`order` may wrap it.
489
+ */
490
+ const QueryAggregateClause_ = Schema.Struct({
491
+ type: Schema.Literal('aggregate'),
492
+ query: Schema.suspend(() => Query),
493
+ aggregates: Schema.Array(GroupAggregate),
494
+ });
495
+
496
+ export interface QueryAggregateClause extends Schema.Schema.Type<typeof QueryAggregateClause_> {}
497
+ export const QueryAggregateClause: Schema.Schema<QueryAggregateClause> = QueryAggregateClause_;
498
+
407
499
  export const QueryFromClause_ = Schema.Struct({
408
500
  type: Schema.Literal('from'),
409
501
  query: Schema.suspend(() => Query),
@@ -432,6 +524,8 @@ const Query_ = Schema.Union(
432
524
  QueryOrderClause,
433
525
  QueryOptionsClause,
434
526
  QueryLimitClause,
527
+ QuerySkipClause,
528
+ QueryAggregateClause,
435
529
  QueryFromClause,
436
530
  ).annotations({ identifier: 'org.dxos.schema.query' });
437
531
 
@@ -511,6 +605,8 @@ export const visit = (query: Query, visitor: (node: Query) => void) => {
511
605
  }),
512
606
  Match.when({ type: 'order' }, ({ query }) => visit(query, visitor)),
513
607
  Match.when({ type: 'limit' }, ({ query }) => visit(query, visitor)),
608
+ Match.when({ type: 'skip' }, ({ query }) => visit(query, visitor)),
609
+ Match.when({ type: 'aggregate' }, ({ query }) => visit(query, visitor)),
514
610
  Match.when({ type: 'from' }, (node) => {
515
611
  visit(node.query, visitor);
516
612
  if (node.from._tag === 'query') {
@@ -537,6 +633,8 @@ export const map = (query: Query, mapper: (node: Query) => Query): Query => {
537
633
  Match.when({ type: 'options' }, (node) => ({ ...node, query: map(node.query, mapper) })),
538
634
  Match.when({ type: 'order' }, (node) => ({ ...node, query: map(node.query, mapper) })),
539
635
  Match.when({ type: 'limit' }, (node) => ({ ...node, query: map(node.query, mapper) })),
636
+ Match.when({ type: 'skip' }, (node) => ({ ...node, query: map(node.query, mapper) })),
637
+ Match.when({ type: 'aggregate' }, (node) => ({ ...node, query: map(node.query, mapper) })),
540
638
  Match.when({ type: 'from' }, (node) => ({
541
639
  ...node,
542
640
  query: map(node.query, mapper),
@@ -570,6 +668,8 @@ export const fold = <T>(query: Query, reducer: (node: Query) => T): T[] => {
570
668
  ),
571
669
  Match.when({ type: 'order' }, ({ query }) => fold(query, reducer)),
572
670
  Match.when({ type: 'limit' }, ({ query }) => fold(query, reducer)),
671
+ Match.when({ type: 'skip' }, ({ query }) => fold(query, reducer)),
672
+ Match.when({ type: 'aggregate' }, ({ query }) => fold(query, reducer)),
573
673
  Match.when({ type: 'from' }, (node) => {
574
674
  const results = fold(node.query, reducer);
575
675
  if (node.from._tag === 'query') {