@dxos/feed 0.0.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/LICENSE +8 -0
- package/README.md +258 -0
- package/package.json +52 -0
- package/src/feed-store.test.ts +357 -0
- package/src/feed-store.ts +377 -0
- package/src/index.ts +6 -0
- package/src/protocol.ts +110 -0
- package/src/sync.test.ts +103 -0
- package/src/testing/index.ts +5 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
Copyright (c) 2022 DXOS
|
|
3
|
+
|
|
4
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
5
|
+
|
|
6
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
7
|
+
|
|
8
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# @dxos/feed
|
|
2
|
+
|
|
3
|
+
Append-only replicatable log consisting of blocks.
|
|
4
|
+
|
|
5
|
+
## Specification
|
|
6
|
+
|
|
7
|
+
### Concept
|
|
8
|
+
|
|
9
|
+
The feed is an append-only replicated log where data is stored in immutable blocks.
|
|
10
|
+
It is designed to run in multiple environments:
|
|
11
|
+
|
|
12
|
+
- **Browser**: Using `wasm-sqlite` in a Client Services Worker.
|
|
13
|
+
- **Node**: Native SQLite.
|
|
14
|
+
- **Cloudflare Workers**: Durable Objects SQL Storage.
|
|
15
|
+
|
|
16
|
+
All environments use `effect-sql` as the abstraction layer.
|
|
17
|
+
|
|
18
|
+
### Capacity
|
|
19
|
+
|
|
20
|
+
- **Feeds per Space**: ~1000
|
|
21
|
+
- **Blocks per Feed**: ~10,000
|
|
22
|
+
|
|
23
|
+
### Replication
|
|
24
|
+
|
|
25
|
+
- **Push**: Peers push blocks **without positions**.
|
|
26
|
+
- **Pull**: Peers pull blocks **with positions**, allowing them to reconstruct the global order locally.
|
|
27
|
+
|
|
28
|
+
#### Partial Replication
|
|
29
|
+
|
|
30
|
+
The protocol supports partial replication:
|
|
31
|
+
|
|
32
|
+
- **Per-Feed Configuration**: Each feed has independent replication settings.
|
|
33
|
+
- **Default**: No replication (None).
|
|
34
|
+
- **Replica Mode**: `replicate(fromPosition)` -> Replicates `[fromPosition, Infinity)`.
|
|
35
|
+
- `fromPosition = 0`: Full History.
|
|
36
|
+
- `fromPosition = current`: Live Tail only.
|
|
37
|
+
- `fromPosition = now - 3days`: Time-based window (Future potential).
|
|
38
|
+
- **Metadata**: Replication settings are stored alongside feed metadata (locally).
|
|
39
|
+
- **Subset of Feeds**: Clients can choose to replicate only a specific subset of feeds within a Space.
|
|
40
|
+
- _TODO: Define mechanism for selecting which feeds to replicate._
|
|
41
|
+
- **Range Selection**: Replication is strictly defined from a specific **start position** to the **end** (future blocks).
|
|
42
|
+
- It is NOT possible to replicate only historical data without subscribing to new data.
|
|
43
|
+
- Logic: `replicate(feedId, fromPosition) -> [fromPosition, Infinity)`
|
|
44
|
+
|
|
45
|
+
### Server Architecture (Cloudflare)
|
|
46
|
+
|
|
47
|
+
- **Initial Strategy**: **One Durable Object (DO) per Space**.
|
|
48
|
+
- All feeds for a single space are managed by a single DO instance.
|
|
49
|
+
- Simplifies consistency and coordination.
|
|
50
|
+
- **Scaling Strategy (Future)**: **Sharding by Feed ID**.
|
|
51
|
+
- Feeds can be distributed across multiple DOs.
|
|
52
|
+
- Shard Key: Prefix of `hash(feedId)` (e.g., first 2 chars).
|
|
53
|
+
|
|
54
|
+
### Ordering
|
|
55
|
+
|
|
56
|
+
Ordering is determined by the following rules:
|
|
57
|
+
|
|
58
|
+
1. **Positioned Blocks**: Blocks that have been assigned a global `position` by the Server.
|
|
59
|
+
- Ordered strictly by `position` (ascending).
|
|
60
|
+
2. **Unpositioned Blocks**: Blocks pending position assignment (locally created or in-flight).
|
|
61
|
+
- Ordered by **Lamport Timestamp** rules:
|
|
62
|
+
1. `sequence` (Ascending)
|
|
63
|
+
2. `actorId` (Lexicographical)
|
|
64
|
+
|
|
65
|
+
`sequence`: Assigned by the peer (Author). `actorId` + `sequence` forms the Lamport timestamp.
|
|
66
|
+
|
|
67
|
+
> `nextSeq = max(previous_seq_in_this_feed) + 1`
|
|
68
|
+
|
|
69
|
+
`position`: Assigned ONLY by the Server. Monotonic counter per Space.
|
|
70
|
+
|
|
71
|
+
### Idempotency
|
|
72
|
+
|
|
73
|
+
The protocol is idempotent. A block is uniquely identified by the tuple:
|
|
74
|
+
|
|
75
|
+
- `(spaceId, feedId, sequence, actorId)`
|
|
76
|
+
OR
|
|
77
|
+
- `(spaceId, feedId, position)` (if positioned)
|
|
78
|
+
|
|
79
|
+
Processing the same block multiple times must be handled gracefully (e.g., `ON CONFLICT DO NOTHING`).
|
|
80
|
+
|
|
81
|
+
### Schema
|
|
82
|
+
|
|
83
|
+
#### Feeds Table (`feeds`)
|
|
84
|
+
|
|
85
|
+
Stores the mapping between global feed identifiers and local integer IDs.
|
|
86
|
+
|
|
87
|
+
| Field | Type | Description |
|
|
88
|
+
| :-------------- | :------- | :---------------------------------------------------------------------------- |
|
|
89
|
+
| `feedPrivateId` | `number` | **Primary Key**. Local integer identifier for the feed (private to the peer). |
|
|
90
|
+
| `spaceId` | `string` | The global Space ID (external). |
|
|
91
|
+
| `feedId` | `string` | The global Feed ID (ULID). |
|
|
92
|
+
| `feedNamespace` | `string` | **Optional**. Application specific namespace for filtering feeds. |
|
|
93
|
+
|
|
94
|
+
**Indexes**:
|
|
95
|
+
|
|
96
|
+
- Unique Index: `(spaceId, feedId)`
|
|
97
|
+
|
|
98
|
+
#### Blocks Table (`blocks`)
|
|
99
|
+
|
|
100
|
+
| Field | Type | Description |
|
|
101
|
+
| :-------------- | :--------------- | :---------------------------------------------------- |
|
|
102
|
+
| `feedPrivateId` | `number` | Foreign key to `feeds.feedPrivateId`. |
|
|
103
|
+
| `position` | `number \| null` | The global position index. `null` if unpositioned. |
|
|
104
|
+
| `sequence` | `number` | **Sequence Number** (Assigned by Author). |
|
|
105
|
+
| `actorId` | `string` | **Actor ID** (Public Key). |
|
|
106
|
+
| `predSequence` | `number` | **Sequence Number** part of the Predecessor ID. |
|
|
107
|
+
| `predActorId` | `string` | **Actor ID** (Public Key) part of the Predecessor ID. |
|
|
108
|
+
| `timestamp` | `number` | Unix timestamp in milliseconds. |
|
|
109
|
+
| `data` | `Uint8Array` | The content of the block. **Immutable**. |
|
|
110
|
+
|
|
111
|
+
**Indexes**:
|
|
112
|
+
|
|
113
|
+
- Unique Index: `(feedPrivateId, position)`
|
|
114
|
+
- Unique Index: `(feedPrivateId, sequence, actorId)`
|
|
115
|
+
|
|
116
|
+
#### Subscriptions Table (`subscriptions`)
|
|
117
|
+
|
|
118
|
+
Stores active subscriptions to optimize wire protocol.
|
|
119
|
+
|
|
120
|
+
| Field | Type | Description |
|
|
121
|
+
| :--------------- | :------- | :---------------------------------------- |
|
|
122
|
+
| `subscriptionId` | `string` | **Primary Key**. Unique ID. |
|
|
123
|
+
| `expiresAt` | `number` | Unix timestamp when subscription expires. |
|
|
124
|
+
| `feedPrivateIds` | `string` | JSON array of `feedPrivateId`s. |
|
|
125
|
+
|
|
126
|
+
### Sync Protocol (Message-Based RPC)
|
|
127
|
+
|
|
128
|
+
The sync protocol is message-based. All messages include a `requestId`. Responses must copy the `requestId` from the request.
|
|
129
|
+
|
|
130
|
+
1. **Query**
|
|
131
|
+
- Input: `requestId`, `(feedIds: string[]) + cursor` OR `(subscriptionId: string) + cursor`
|
|
132
|
+
- Output: `requestId`, `Block[]` (Stream or Batch)
|
|
133
|
+
- Description: Returns blocks from the specified feeds (or subscription) with `position > cursor`.
|
|
134
|
+
|
|
135
|
+
2. **Subscribe**
|
|
136
|
+
- Input: `requestId`, `feedIds: string[]`
|
|
137
|
+
- Output: `requestId`, `subscriptionId: string`
|
|
138
|
+
- Description: Registers a subscription for specific feeds. Returns a `subscriptionId`.
|
|
139
|
+
|
|
140
|
+
3. **Append**
|
|
141
|
+
- Input: `requestId`, `namespace?: string`, `blocks: Block[]`
|
|
142
|
+
- Output: `requestId`, `positions: number[]`
|
|
143
|
+
- Description: Appends blocks to their respective feeds.
|
|
144
|
+
- If `namespace` is provided and the feed is new, it sets the feed's namespace.
|
|
145
|
+
- Returns the assigned global positions.
|
|
146
|
+
|
|
147
|
+
### Logic
|
|
148
|
+
|
|
149
|
+
#### Feed Identification
|
|
150
|
+
|
|
151
|
+
A feed is globally identified by the tuple `(spaceId, feedId)`.
|
|
152
|
+
|
|
153
|
+
- `spaceId`: String provided externally.
|
|
154
|
+
- `feedId`: ULID string.
|
|
155
|
+
|
|
156
|
+
#### Timestamps
|
|
157
|
+
|
|
158
|
+
All timestamps (e.g., `insertionTimestamp`) are Unix timestamps in milliseconds.
|
|
159
|
+
|
|
160
|
+
## Usage Examples
|
|
161
|
+
|
|
162
|
+
### 1. Append Blocks
|
|
163
|
+
|
|
164
|
+
**Request:**
|
|
165
|
+
|
|
166
|
+
```json
|
|
167
|
+
{
|
|
168
|
+
"requestId": "req-1",
|
|
169
|
+
"namespace": "my-app-data",
|
|
170
|
+
"blocks": [
|
|
171
|
+
{
|
|
172
|
+
"actorId": "01H1...",
|
|
173
|
+
"sequence": 100,
|
|
174
|
+
"data": "...",
|
|
175
|
+
"timestamp": 1234567890
|
|
176
|
+
}
|
|
177
|
+
]
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
**Response:**
|
|
182
|
+
|
|
183
|
+
```json
|
|
184
|
+
{
|
|
185
|
+
"requestId": "req-1",
|
|
186
|
+
"positions": [42]
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### 2. Subscribe and Query
|
|
191
|
+
|
|
192
|
+
**Subscribe Request:**
|
|
193
|
+
|
|
194
|
+
```json
|
|
195
|
+
{
|
|
196
|
+
"requestId": "req-2",
|
|
197
|
+
"feedIds": ["01H1..."]
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
**Subscribe Response:**
|
|
202
|
+
|
|
203
|
+
```json
|
|
204
|
+
{
|
|
205
|
+
"requestId": "req-2",
|
|
206
|
+
"subscriptionId": "sub-123",
|
|
207
|
+
"expiresAt": 1234569999
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### 3. List Feeds
|
|
212
|
+
|
|
213
|
+
**Request:**
|
|
214
|
+
|
|
215
|
+
```json
|
|
216
|
+
{
|
|
217
|
+
"requestId": "req-4",
|
|
218
|
+
"namespace": "data"
|
|
219
|
+
}
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
**Response:**
|
|
223
|
+
|
|
224
|
+
```json
|
|
225
|
+
{
|
|
226
|
+
"requestId": "req-4",
|
|
227
|
+
"feeds": [
|
|
228
|
+
{ "feedId": "01H1...", "namespace": "data" },
|
|
229
|
+
{ "feedId": "02H2...", "namespace": "data" }
|
|
230
|
+
]
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
**Query Request (Poll using Subscription):**
|
|
235
|
+
|
|
236
|
+
```json
|
|
237
|
+
{
|
|
238
|
+
"requestId": "req-3",
|
|
239
|
+
"subscriptionId": "sub-123",
|
|
240
|
+
"cursor": 41
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
**Query Response:**
|
|
245
|
+
|
|
246
|
+
```json
|
|
247
|
+
{
|
|
248
|
+
"requestId": "req-3",
|
|
249
|
+
"blocks": [
|
|
250
|
+
{
|
|
251
|
+
"position": 42,
|
|
252
|
+
"sequence": 100,
|
|
253
|
+
"actorId": "01H1...",
|
|
254
|
+
"data": "..."
|
|
255
|
+
}
|
|
256
|
+
]
|
|
257
|
+
}
|
|
258
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dxos/feed",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Append-only replicatable log",
|
|
5
|
+
"homepage": "https://dxos.org",
|
|
6
|
+
"bugs": "https://github.com/dxos/dxos/issues",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"author": "info@dxos.org",
|
|
9
|
+
"sideEffects": true,
|
|
10
|
+
"type": "module",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/types/src/index.d.ts",
|
|
14
|
+
"source": "./src/index.ts",
|
|
15
|
+
"browser": "./dist/lib/browser/index.mjs",
|
|
16
|
+
"node": "./dist/lib/node-esm/index.mjs"
|
|
17
|
+
},
|
|
18
|
+
"./testing": {
|
|
19
|
+
"types": "./dist/types/src/testing/index.d.ts",
|
|
20
|
+
"source": "./src/testing/index.ts",
|
|
21
|
+
"browser": "./dist/lib/browser/testing/index.mjs",
|
|
22
|
+
"node": "./dist/lib/node-esm/testing/index.mjs"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"types": "dist/types/src/index.d.ts",
|
|
26
|
+
"typesVersions": {
|
|
27
|
+
"*": {
|
|
28
|
+
"testing": [
|
|
29
|
+
"dist/types/src/testing/index.d.ts"
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"dist",
|
|
35
|
+
"src"
|
|
36
|
+
],
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@effect/sql": "0.48.6",
|
|
39
|
+
"@effect/sql-sqlite-node": "0.49.1",
|
|
40
|
+
"@effect/vitest": "0.27.0",
|
|
41
|
+
"effect": "3.19.11",
|
|
42
|
+
"@dxos/keys": "0.8.3",
|
|
43
|
+
"@dxos/async": "0.8.3",
|
|
44
|
+
"@dxos/log": "0.8.3",
|
|
45
|
+
"@dxos/invariant": "0.8.3",
|
|
46
|
+
"@dxos/util": "0.8.3"
|
|
47
|
+
},
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public"
|
|
50
|
+
},
|
|
51
|
+
"beast": {}
|
|
52
|
+
}
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as SqliteClient from '@effect/sql-sqlite-node/SqliteClient';
|
|
6
|
+
import { describe, expect, it } from '@effect/vitest';
|
|
7
|
+
import * as Effect from 'effect/Effect';
|
|
8
|
+
|
|
9
|
+
import { ObjectId, SpaceId } from '@dxos/keys';
|
|
10
|
+
import { log } from '@dxos/log';
|
|
11
|
+
|
|
12
|
+
import { FeedStore } from './feed-store';
|
|
13
|
+
import { Block } from './protocol';
|
|
14
|
+
|
|
15
|
+
const TestLayer = SqliteClient.layer({
|
|
16
|
+
filename: ':memory:',
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// ActorIds.
|
|
20
|
+
const ALICE = 'alice';
|
|
21
|
+
const BOB = 'bob';
|
|
22
|
+
|
|
23
|
+
describe('Feed V2', () => {
|
|
24
|
+
it.effect('should append and query blocks via RPC', () =>
|
|
25
|
+
Effect.gen(function* () {
|
|
26
|
+
const spaceId = SpaceId.random();
|
|
27
|
+
const feedId = ObjectId.random();
|
|
28
|
+
|
|
29
|
+
const feed = new FeedStore({ localActorId: ALICE, assignPositions: true });
|
|
30
|
+
yield* feed.migrate();
|
|
31
|
+
|
|
32
|
+
// Append
|
|
33
|
+
const block: Block = {
|
|
34
|
+
feedId: undefined,
|
|
35
|
+
actorId: feedId,
|
|
36
|
+
sequence: 123, // Author sequence provided by peer
|
|
37
|
+
predActorId: null,
|
|
38
|
+
predSequence: null,
|
|
39
|
+
position: null, // Input doesn't have position
|
|
40
|
+
timestamp: Date.now(),
|
|
41
|
+
data: new Uint8Array([1, 2, 3]),
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const appendRes = yield* feed.append({ requestId: 'req-1', blocks: [block], spaceId, feedId, namespace: 'data' });
|
|
45
|
+
expect(appendRes.positions.length).toBe(1);
|
|
46
|
+
expect(appendRes.positions[0]).toBeDefined();
|
|
47
|
+
expect(appendRes.requestId).toBe('req-1');
|
|
48
|
+
|
|
49
|
+
// Query by feedId
|
|
50
|
+
const queryRes = yield* feed.query({ requestId: 'req-2', query: { feedIds: [feedId] }, position: -1, spaceId }); // Use position -1 to get everything
|
|
51
|
+
expect(queryRes.blocks.length).toBe(1);
|
|
52
|
+
expect(queryRes.blocks[0].position).toBe(appendRes.positions[0]);
|
|
53
|
+
expect(queryRes.blocks[0].sequence).toBe(123); // Verify Author Sequence is preserved
|
|
54
|
+
expect(queryRes.requestId).toBe('req-2');
|
|
55
|
+
}).pipe(Effect.provide(TestLayer)),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
it.effect('should persist feed namespace', () =>
|
|
59
|
+
Effect.gen(function* () {
|
|
60
|
+
const spaceId = SpaceId.random();
|
|
61
|
+
const feedId = ObjectId.random();
|
|
62
|
+
const namespace = 'data';
|
|
63
|
+
|
|
64
|
+
const feed = new FeedStore({ localActorId: ALICE, assignPositions: true });
|
|
65
|
+
yield* feed.migrate();
|
|
66
|
+
|
|
67
|
+
// Append with namespace
|
|
68
|
+
const block = Block.make({
|
|
69
|
+
feedId: undefined,
|
|
70
|
+
actorId: ALICE,
|
|
71
|
+
sequence: 1,
|
|
72
|
+
predActorId: null,
|
|
73
|
+
predSequence: null,
|
|
74
|
+
position: null,
|
|
75
|
+
timestamp: Date.now(),
|
|
76
|
+
data: new Uint8Array([1]),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
yield* feed.append({ requestId: 'req-ns', blocks: [block], namespace, spaceId, feedId });
|
|
80
|
+
|
|
81
|
+
// Verify directly from DB (white-box test) to ensure schema is correct
|
|
82
|
+
const sql = yield* SqliteClient.SqliteClient;
|
|
83
|
+
const rows = yield* sql<{ feedNamespace: string }>`
|
|
84
|
+
SELECT feedNamespace FROM feeds WHERE spaceId = ${spaceId} AND feedId = ${feedId}
|
|
85
|
+
`;
|
|
86
|
+
expect(rows.length).toBeGreaterThan(0);
|
|
87
|
+
expect(rows[0].feedNamespace).toBe(namespace);
|
|
88
|
+
}).pipe(Effect.provide(TestLayer)),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
it.effect('should use subscriptions', () =>
|
|
92
|
+
Effect.gen(function* () {
|
|
93
|
+
const spaceId = SpaceId.random();
|
|
94
|
+
const feedId = ObjectId.random();
|
|
95
|
+
|
|
96
|
+
const feed = new FeedStore({ localActorId: ALICE, assignPositions: true });
|
|
97
|
+
yield* feed.migrate();
|
|
98
|
+
|
|
99
|
+
// Append some data
|
|
100
|
+
yield* feed.append({
|
|
101
|
+
requestId: 'req-1',
|
|
102
|
+
blocks: [
|
|
103
|
+
Block.make({
|
|
104
|
+
feedId: undefined,
|
|
105
|
+
actorId: feedId,
|
|
106
|
+
sequence: 1,
|
|
107
|
+
predActorId: null,
|
|
108
|
+
predSequence: null,
|
|
109
|
+
position: null,
|
|
110
|
+
timestamp: Date.now(),
|
|
111
|
+
data: new Uint8Array([1]),
|
|
112
|
+
}),
|
|
113
|
+
],
|
|
114
|
+
spaceId,
|
|
115
|
+
feedId,
|
|
116
|
+
namespace: 'data',
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Subscribe
|
|
120
|
+
const subRes = yield* feed.subscribe({ requestId: 'req-2', feedIds: [feedId], spaceId });
|
|
121
|
+
expect(subRes.subscriptionId).toBeDefined();
|
|
122
|
+
expect(subRes.requestId).toBe('req-2');
|
|
123
|
+
|
|
124
|
+
// Query via Subscription
|
|
125
|
+
const queryRes = yield* feed.query({
|
|
126
|
+
requestId: 'req-3',
|
|
127
|
+
spaceId,
|
|
128
|
+
query: { subscriptionId: subRes.subscriptionId },
|
|
129
|
+
position: 0,
|
|
130
|
+
});
|
|
131
|
+
expect(queryRes.blocks.length).toBe(0);
|
|
132
|
+
expect(queryRes.requestId).toBe('req-3');
|
|
133
|
+
}).pipe(Effect.provide(TestLayer)),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
it.effect('should assign monotonic insertionId', () =>
|
|
137
|
+
Effect.gen(function* () {
|
|
138
|
+
const feedStore = new FeedStore({ localActorId: ALICE, assignPositions: true });
|
|
139
|
+
yield* feedStore.migrate();
|
|
140
|
+
|
|
141
|
+
const spaceId = SpaceId.random();
|
|
142
|
+
|
|
143
|
+
yield* feedStore.appendLocal([
|
|
144
|
+
{
|
|
145
|
+
spaceId,
|
|
146
|
+
feedId: 'feed-1',
|
|
147
|
+
feedNamespace: 'default',
|
|
148
|
+
data: new Uint8Array([1]),
|
|
149
|
+
},
|
|
150
|
+
]);
|
|
151
|
+
|
|
152
|
+
yield* feedStore.appendLocal([
|
|
153
|
+
{
|
|
154
|
+
spaceId,
|
|
155
|
+
feedId: 'feed-2',
|
|
156
|
+
feedNamespace: 'default',
|
|
157
|
+
data: new Uint8Array([2]),
|
|
158
|
+
},
|
|
159
|
+
]);
|
|
160
|
+
|
|
161
|
+
const result1 = yield* feedStore.query({
|
|
162
|
+
requestId: 'req1',
|
|
163
|
+
spaceId,
|
|
164
|
+
query: { feedIds: ['feed-1'] },
|
|
165
|
+
position: -1,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const result2 = yield* feedStore.query({
|
|
169
|
+
requestId: 'req2',
|
|
170
|
+
spaceId,
|
|
171
|
+
query: { feedIds: ['feed-2'] },
|
|
172
|
+
position: -1,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
expect(result1.blocks[0].insertionId).toBeTypeOf('number');
|
|
176
|
+
expect(result2.blocks[0].insertionId).toBeTypeOf('number');
|
|
177
|
+
expect(result2.blocks[0].insertionId!).toBeGreaterThan(result1.blocks[0].insertionId!);
|
|
178
|
+
}).pipe(Effect.provide(TestLayer)),
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
it.effect('should assign monotonic insertionId and support token based cursor', () =>
|
|
182
|
+
Effect.gen(function* () {
|
|
183
|
+
const feedStore = new FeedStore({ localActorId: ALICE, assignPositions: true });
|
|
184
|
+
yield* feedStore.migrate();
|
|
185
|
+
|
|
186
|
+
const spaceId = SpaceId.random();
|
|
187
|
+
|
|
188
|
+
// Append interleaving blocks
|
|
189
|
+
yield* feedStore.appendLocal([
|
|
190
|
+
{
|
|
191
|
+
spaceId,
|
|
192
|
+
feedId: 'feed-1',
|
|
193
|
+
feedNamespace: 'default',
|
|
194
|
+
data: new Uint8Array([1]),
|
|
195
|
+
},
|
|
196
|
+
]);
|
|
197
|
+
yield* feedStore.appendLocal([
|
|
198
|
+
{
|
|
199
|
+
spaceId,
|
|
200
|
+
feedId: 'feed-2',
|
|
201
|
+
feedNamespace: 'default',
|
|
202
|
+
data: new Uint8Array([2]),
|
|
203
|
+
},
|
|
204
|
+
]);
|
|
205
|
+
yield* feedStore.appendLocal([
|
|
206
|
+
{
|
|
207
|
+
spaceId,
|
|
208
|
+
feedId: 'feed-1',
|
|
209
|
+
feedNamespace: 'default',
|
|
210
|
+
data: new Uint8Array([3]),
|
|
211
|
+
},
|
|
212
|
+
]);
|
|
213
|
+
|
|
214
|
+
// Query all with feedId (simulating unified query with no cursor initially)
|
|
215
|
+
const feed1Res = yield* feedStore.query({
|
|
216
|
+
requestId: 'req1',
|
|
217
|
+
spaceId,
|
|
218
|
+
query: { feedIds: ['feed-1'] },
|
|
219
|
+
cursor: undefined,
|
|
220
|
+
});
|
|
221
|
+
const feed2Res = yield* feedStore.query({
|
|
222
|
+
requestId: 'req2',
|
|
223
|
+
spaceId,
|
|
224
|
+
query: { feedIds: ['feed-2'] },
|
|
225
|
+
cursor: undefined,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// Verify insertionId consistency
|
|
229
|
+
const block1 = feed1Res.blocks[0]; // data=[1]
|
|
230
|
+
const block2 = feed2Res.blocks[0]; // data=[2]
|
|
231
|
+
const block3 = feed1Res.blocks[1]; // data=[3]
|
|
232
|
+
|
|
233
|
+
expect(block1.insertionId).toBeLessThan(block2.insertionId!);
|
|
234
|
+
expect(block2.insertionId).toBeLessThan(block3.insertionId!);
|
|
235
|
+
|
|
236
|
+
// Verify Next Cursor format (Token|InsertionId)
|
|
237
|
+
expect(feed1Res.nextCursor).toBeDefined();
|
|
238
|
+
expect(feed1Res.nextCursor).toContain('|');
|
|
239
|
+
|
|
240
|
+
// Test Query with invalid cursor token
|
|
241
|
+
const invalidCursor = 'badtoken|0';
|
|
242
|
+
const result = yield* feedStore
|
|
243
|
+
.query({
|
|
244
|
+
requestId: 'req-bad',
|
|
245
|
+
spaceId,
|
|
246
|
+
query: { feedIds: ['feed-1'] },
|
|
247
|
+
cursor: invalidCursor as any,
|
|
248
|
+
})
|
|
249
|
+
.pipe(Effect.exit);
|
|
250
|
+
|
|
251
|
+
expect(result._tag).toBe('Failure');
|
|
252
|
+
if (result._tag === 'Failure') {
|
|
253
|
+
const cause: any = result.cause;
|
|
254
|
+
|
|
255
|
+
// Handling Effect Cause structure which might be diff in this version
|
|
256
|
+
// Ideally we use Cause.isDie(cause) -> error
|
|
257
|
+
// But for quick check:
|
|
258
|
+
let error = cause.value || cause.defect || cause;
|
|
259
|
+
if (cause._tag === 'Die') error = cause.value || cause.defect;
|
|
260
|
+
|
|
261
|
+
expect(error).toBeDefined();
|
|
262
|
+
expect(error.message).toBe('Cursor token mismatch');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Test Query with VALID cursor
|
|
266
|
+
// Use the cursor from the first block of feed-1 to get the second block
|
|
267
|
+
// Construct cursor manually or assume we got it from somewhere.
|
|
268
|
+
// Actually `nextCursor` points to the END.
|
|
269
|
+
// Let's manually construct a cursor to point after the first item.
|
|
270
|
+
// We need the token.
|
|
271
|
+
// We can get it from nextCursor.
|
|
272
|
+
const token = feed1Res.nextCursor.split('|')[0];
|
|
273
|
+
const validCursor = `${token}|${block1.insertionId}`;
|
|
274
|
+
|
|
275
|
+
const nextRes = yield* feedStore.query({
|
|
276
|
+
requestId: 'req-next',
|
|
277
|
+
spaceId,
|
|
278
|
+
query: { feedIds: ['feed-1'] },
|
|
279
|
+
cursor: validCursor as any,
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
expect(nextRes.blocks.length).toBe(1);
|
|
283
|
+
expect(nextRes.blocks[0].data[0]).toBe(3);
|
|
284
|
+
}).pipe(Effect.provide(TestLayer)),
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
it.effect('append local', () =>
|
|
288
|
+
Effect.gen(function* () {
|
|
289
|
+
const spaceId = SpaceId.random();
|
|
290
|
+
const feedId = ObjectId.random();
|
|
291
|
+
|
|
292
|
+
const feed = new FeedStore({ localActorId: ALICE, assignPositions: true });
|
|
293
|
+
yield* feed.migrate();
|
|
294
|
+
|
|
295
|
+
const blocks = yield* feed.appendLocal([
|
|
296
|
+
{
|
|
297
|
+
spaceId,
|
|
298
|
+
feedId,
|
|
299
|
+
feedNamespace: 'data',
|
|
300
|
+
data: new Uint8Array([1]),
|
|
301
|
+
},
|
|
302
|
+
]);
|
|
303
|
+
expect(blocks.length).toBe(1);
|
|
304
|
+
expect(blocks[0].position).toBeNull();
|
|
305
|
+
expect(blocks[0].sequence).toBe(0);
|
|
306
|
+
expect(blocks[0].actorId).toBe(ALICE);
|
|
307
|
+
expect(blocks[0].predActorId).toBeNull();
|
|
308
|
+
expect(blocks[0].predSequence).toBeNull();
|
|
309
|
+
expect(blocks[0].timestamp).toBeGreaterThan(0);
|
|
310
|
+
expect(blocks[0].data).toEqual(new Uint8Array([1]));
|
|
311
|
+
|
|
312
|
+
// Query by feedId
|
|
313
|
+
const queryRes = yield* feed.query({ query: { feedIds: [feedId] }, position: -1, spaceId }); // Use position '-1' to get everything
|
|
314
|
+
expect(queryRes.blocks.length).toBe(1);
|
|
315
|
+
expect(queryRes.blocks.length).toBe(1);
|
|
316
|
+
expect(queryRes.blocks[0]).toMatchObject({ ...blocks[0], feedId, position: expect.any(Number) });
|
|
317
|
+
}).pipe(Effect.provide(TestLayer)),
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
it.effect('tailing a feed', () =>
|
|
321
|
+
Effect.gen(function* () {
|
|
322
|
+
const spaceId = SpaceId.random();
|
|
323
|
+
const feedId = ObjectId.random();
|
|
324
|
+
|
|
325
|
+
const feed = new FeedStore({ localActorId: ALICE, assignPositions: true });
|
|
326
|
+
yield* feed.migrate();
|
|
327
|
+
|
|
328
|
+
yield* feed.appendLocal([
|
|
329
|
+
{
|
|
330
|
+
spaceId,
|
|
331
|
+
feedId,
|
|
332
|
+
feedNamespace: 'data',
|
|
333
|
+
data: new Uint8Array([1]),
|
|
334
|
+
},
|
|
335
|
+
]);
|
|
336
|
+
const query1 = yield* feed.query({ spaceId, query: { feedIds: [feedId] } }); // Use position '-1' to get everything
|
|
337
|
+
log.info('query 1', { blocks: query1.blocks.length, cursor: query1.nextCursor });
|
|
338
|
+
expect(query1.blocks.length).toBe(1);
|
|
339
|
+
|
|
340
|
+
const query2 = yield* feed.query({ spaceId, query: { feedIds: [feedId] }, cursor: query1.nextCursor });
|
|
341
|
+
log.info('query 2', { blocks: query2.blocks.length, cursor: query2.nextCursor });
|
|
342
|
+
expect(query2.blocks.length).toBe(0);
|
|
343
|
+
|
|
344
|
+
yield* feed.appendLocal([
|
|
345
|
+
{
|
|
346
|
+
spaceId,
|
|
347
|
+
feedId,
|
|
348
|
+
feedNamespace: 'data',
|
|
349
|
+
data: new Uint8Array([2]),
|
|
350
|
+
},
|
|
351
|
+
]);
|
|
352
|
+
const query3 = yield* feed.query({ spaceId, query: { feedIds: [feedId] }, cursor: query2.nextCursor });
|
|
353
|
+
log.info('query 3', { blocks: query3.blocks.length, cursor: query3.nextCursor });
|
|
354
|
+
expect(query3.blocks.length).toBe(1);
|
|
355
|
+
}).pipe(Effect.provide(TestLayer)),
|
|
356
|
+
);
|
|
357
|
+
});
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as SqlClient from '@effect/sql/SqlClient';
|
|
6
|
+
import type * as SqlError from '@effect/sql/SqlError';
|
|
7
|
+
import * as Effect from 'effect/Effect';
|
|
8
|
+
|
|
9
|
+
import { Event } from '@dxos/async';
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
type AppendRequest,
|
|
13
|
+
type AppendResponse,
|
|
14
|
+
type Block,
|
|
15
|
+
FeedCursor,
|
|
16
|
+
type QueryRequest,
|
|
17
|
+
type QueryResponse,
|
|
18
|
+
type SubscribeRequest,
|
|
19
|
+
type SubscribeResponse,
|
|
20
|
+
} from './protocol';
|
|
21
|
+
|
|
22
|
+
export interface FeedStoreOptions {
|
|
23
|
+
localActorId: string;
|
|
24
|
+
assignPositions: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class FeedStore {
|
|
28
|
+
constructor(private readonly _options: FeedStoreOptions) {}
|
|
29
|
+
|
|
30
|
+
readonly onNewBlocks = new Event<void>();
|
|
31
|
+
|
|
32
|
+
migrate = Effect.fn('FeedStore.migrate')(function* () {
|
|
33
|
+
const sql = yield* SqlClient.SqlClient;
|
|
34
|
+
|
|
35
|
+
// Feeds Table
|
|
36
|
+
yield* sql`CREATE TABLE IF NOT EXISTS feeds (
|
|
37
|
+
feedPrivateId INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
38
|
+
spaceId TEXT NOT NULL,
|
|
39
|
+
feedId TEXT NOT NULL,
|
|
40
|
+
feedNamespace TEXT
|
|
41
|
+
)`;
|
|
42
|
+
yield* sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_feeds_spaceId_feedId ON feeds(spaceId, feedId)`;
|
|
43
|
+
|
|
44
|
+
// Blocks Table
|
|
45
|
+
yield* sql`CREATE TABLE IF NOT EXISTS blocks (
|
|
46
|
+
insertionId INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
47
|
+
feedPrivateId INTEGER NOT NULL,
|
|
48
|
+
position INTEGER,
|
|
49
|
+
sequence INTEGER NOT NULL,
|
|
50
|
+
actorId TEXT NOT NULL,
|
|
51
|
+
predSequence INTEGER,
|
|
52
|
+
predActorId TEXT,
|
|
53
|
+
timestamp INTEGER NOT NULL,
|
|
54
|
+
data BLOB NOT NULL,
|
|
55
|
+
FOREIGN KEY(feedPrivateId) REFERENCES feeds(feedPrivateId)
|
|
56
|
+
)`;
|
|
57
|
+
yield* sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_blocks_feedPrivateId_position ON blocks(feedPrivateId, position)`;
|
|
58
|
+
yield* sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_blocks_feedPrivateId_sequence_actorId ON blocks(feedPrivateId, sequence, actorId)`;
|
|
59
|
+
|
|
60
|
+
// Subscriptions Table
|
|
61
|
+
yield* sql`CREATE TABLE IF NOT EXISTS subscriptions (
|
|
62
|
+
subscriptionId TEXT PRIMARY KEY,
|
|
63
|
+
expiresAt INTEGER NOT NULL,
|
|
64
|
+
feedPrivateIds TEXT NOT NULL -- JSON array
|
|
65
|
+
)`;
|
|
66
|
+
|
|
67
|
+
// Cursor Tokens Table
|
|
68
|
+
yield* sql`CREATE TABLE IF NOT EXISTS cursor_tokens (
|
|
69
|
+
spaceId TEXT PRIMARY KEY,
|
|
70
|
+
token TEXT NOT NULL
|
|
71
|
+
)`;
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Internal Logic
|
|
75
|
+
|
|
76
|
+
private _ensureFeed = Effect.fn('Feed.ensureFeed')(
|
|
77
|
+
(
|
|
78
|
+
spaceId: string,
|
|
79
|
+
feedId: string,
|
|
80
|
+
namespace?: string,
|
|
81
|
+
): Effect.Effect<number, SqlError.SqlError, SqlClient.SqlClient> =>
|
|
82
|
+
Effect.gen(this, function* () {
|
|
83
|
+
const sql = yield* SqlClient.SqlClient;
|
|
84
|
+
|
|
85
|
+
const rows = yield* sql<{ feedPrivateId: number }>`
|
|
86
|
+
SELECT feedPrivateId FROM feeds WHERE spaceId = ${spaceId} AND feedId = ${feedId}
|
|
87
|
+
`;
|
|
88
|
+
if (rows.length > 0) return rows[0].feedPrivateId;
|
|
89
|
+
|
|
90
|
+
const newRows = yield* sql<{ feedPrivateId: number }>`
|
|
91
|
+
INSERT INTO feeds (spaceId, feedId, feedNamespace) VALUES (${spaceId}, ${feedId}, ${namespace}) RETURNING feedPrivateId
|
|
92
|
+
`;
|
|
93
|
+
return newRows[0].feedPrivateId;
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
private _ensureCursorToken = Effect.fn('Feed.ensureCursorToken')(
|
|
98
|
+
(spaceId: string): Effect.Effect<string, SqlError.SqlError, SqlClient.SqlClient> =>
|
|
99
|
+
Effect.gen(this, function* () {
|
|
100
|
+
const sql = yield* SqlClient.SqlClient;
|
|
101
|
+
const rows = yield* sql<{ token: string }>`SELECT token FROM cursor_tokens WHERE spaceId = ${spaceId}`;
|
|
102
|
+
if (rows.length > 0) return rows[0].token;
|
|
103
|
+
|
|
104
|
+
const token = crypto.randomUUID().replace(/-/g, '').slice(0, 6);
|
|
105
|
+
yield* sql`INSERT INTO cursor_tokens (spaceId, token) VALUES (${spaceId}, ${token})`;
|
|
106
|
+
return token;
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
// RPCs
|
|
111
|
+
|
|
112
|
+
query = Effect.fn('Feed.query')(
|
|
113
|
+
(request: QueryRequest): Effect.Effect<QueryResponse, SqlError.SqlError, SqlClient.SqlClient> =>
|
|
114
|
+
Effect.gen(this, function* () {
|
|
115
|
+
const sql = yield* SqlClient.SqlClient;
|
|
116
|
+
let feedIds: string[] | undefined = [];
|
|
117
|
+
let cursorInsertionId = -1;
|
|
118
|
+
let cursorToken: string | undefined;
|
|
119
|
+
|
|
120
|
+
if (!request.spaceId) {
|
|
121
|
+
return yield* Effect.die(new Error('spaceId is required'));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (request.cursor) {
|
|
125
|
+
const { token, insertionId } = decodeCursor(request.cursor as FeedCursor);
|
|
126
|
+
if (!token || insertionId === undefined || isNaN(insertionId)) {
|
|
127
|
+
return yield* Effect.die(new Error(`Invalid cursor format`));
|
|
128
|
+
}
|
|
129
|
+
cursorToken = token;
|
|
130
|
+
cursorInsertionId = insertionId;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Validate Token if cursor used
|
|
134
|
+
const validCursorToken = yield* this._ensureCursorToken(request.spaceId);
|
|
135
|
+
if (request.cursor && cursorToken !== validCursorToken) {
|
|
136
|
+
return yield* Effect.die(new Error(`Cursor token mismatch`));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// If cursor is provided, we must validate it against the space token.
|
|
140
|
+
// If spaceId is not provided in request (e.g. feedIds query), we can't easily validate token unless we look up spaceId for feedIds.
|
|
141
|
+
// Ideally spaceId should be required for token validation.
|
|
142
|
+
|
|
143
|
+
/*
|
|
144
|
+
Logic:
|
|
145
|
+
1. If `cursor` is present, it's `token|insertionId`.
|
|
146
|
+
2. If `position` is present, it's `position` (legacy/manual).
|
|
147
|
+
|
|
148
|
+
We prioritize `cursor`.
|
|
149
|
+
*/
|
|
150
|
+
|
|
151
|
+
const position = request.position ?? -1;
|
|
152
|
+
|
|
153
|
+
// Resolve Subscriptions or FeedIds
|
|
154
|
+
if ('subscriptionId' in request.query) {
|
|
155
|
+
const rows = yield* sql<{ feedPrivateIds: string; expiresAt: number }>`
|
|
156
|
+
SELECT feedPrivateIds, expiresAt FROM subscriptions WHERE subscriptionId = ${request.query.subscriptionId}
|
|
157
|
+
`;
|
|
158
|
+
if (rows.length > 0) {
|
|
159
|
+
const { feedPrivateIds, expiresAt } = rows[0];
|
|
160
|
+
if (Date.now() <= expiresAt) {
|
|
161
|
+
const privateIds = JSON.parse(feedPrivateIds) as number[];
|
|
162
|
+
if (privateIds.length > 0) {
|
|
163
|
+
const feedRows = yield* sql<{ feedId: string }>`
|
|
164
|
+
SELECT feedId FROM feeds WHERE feedPrivateId IN ${sql.in(privateIds)}
|
|
165
|
+
`;
|
|
166
|
+
feedIds = feedRows.map((r) => r.feedId);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
} else if ('feedIds' in request.query) {
|
|
171
|
+
feedIds = [...(request.query as any).feedIds];
|
|
172
|
+
} else {
|
|
173
|
+
feedIds = undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (feedIds !== undefined && feedIds.length === 0) {
|
|
177
|
+
return { requestId: request.requestId, blocks: [], nextCursor: encodeCursor(validCursorToken, -1) };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Fetch Blocks
|
|
181
|
+
const query = sql<Block>`
|
|
182
|
+
SELECT blocks.*, feeds.feedId
|
|
183
|
+
FROM blocks
|
|
184
|
+
JOIN feeds ON blocks.feedPrivateId = feeds.feedPrivateId
|
|
185
|
+
WHERE 1=1
|
|
186
|
+
${feedIds !== undefined ? sql`AND feeds.feedId IN ${sql.in(feedIds)}` : sql``}
|
|
187
|
+
${request.spaceId ? sql`AND feeds.spaceId = ${request.spaceId}` : sql``}
|
|
188
|
+
${
|
|
189
|
+
'feedNamespace' in request.query && request.query.feedNamespace
|
|
190
|
+
? sql`AND feeds.feedNamespace = ${request.query.feedNamespace}`
|
|
191
|
+
: sql``
|
|
192
|
+
}
|
|
193
|
+
`;
|
|
194
|
+
|
|
195
|
+
// Add filter based on cursor or position
|
|
196
|
+
const filter = request.cursor
|
|
197
|
+
? sql`AND blocks.insertionId > ${cursorInsertionId}`
|
|
198
|
+
: sql`AND (blocks.position > ${position} OR blocks.position IS NULL)`;
|
|
199
|
+
|
|
200
|
+
const orderBy = request.cursor
|
|
201
|
+
? sql`ORDER BY blocks.insertionId ASC`
|
|
202
|
+
: sql`ORDER BY blocks.position ASC NULLS LAST`;
|
|
203
|
+
|
|
204
|
+
const rows = yield* sql<Block>`
|
|
205
|
+
${query}
|
|
206
|
+
${filter}
|
|
207
|
+
${orderBy}
|
|
208
|
+
${request.limit ? sql`LIMIT ${request.limit}` : sql``}
|
|
209
|
+
`;
|
|
210
|
+
|
|
211
|
+
const blocks = rows.map((row) => ({
|
|
212
|
+
...row,
|
|
213
|
+
// Have to buffer otherwise we get empty Uint8Array.
|
|
214
|
+
data: new Uint8Array(row.data),
|
|
215
|
+
}));
|
|
216
|
+
|
|
217
|
+
let nextCursor: FeedCursor = request.cursor ?? encodeCursor(validCursorToken, -1);
|
|
218
|
+
if (blocks.length > 0 && request.spaceId) {
|
|
219
|
+
const lastBlock = blocks[blocks.length - 1];
|
|
220
|
+
if (lastBlock.insertionId !== undefined) {
|
|
221
|
+
nextCursor = encodeCursor(validCursorToken, lastBlock.insertionId);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return { requestId: request.requestId, blocks, nextCursor } satisfies QueryResponse;
|
|
226
|
+
}),
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
subscribe = Effect.fn('Feed.subscribe')(
|
|
230
|
+
(request: SubscribeRequest): Effect.Effect<SubscribeResponse, SqlError.SqlError, SqlClient.SqlClient> =>
|
|
231
|
+
Effect.gen(this, function* () {
|
|
232
|
+
const sql = yield* SqlClient.SqlClient;
|
|
233
|
+
const ttl = 60 * 60 * 1000;
|
|
234
|
+
const subscriptionId = crypto.randomUUID();
|
|
235
|
+
const expiresAt = Date.now() + ttl;
|
|
236
|
+
|
|
237
|
+
if (!request.spaceId) {
|
|
238
|
+
// TODO(dmaretskyi): Define error type.
|
|
239
|
+
return yield* Effect.die(new Error('spaceId required for subscribe'));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const feedPrivateIds = yield* Effect.forEach(
|
|
243
|
+
request.feedIds,
|
|
244
|
+
(feedId) => this._ensureFeed(request.spaceId!, feedId),
|
|
245
|
+
{ concurrency: 'unbounded' },
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
yield* sql`
|
|
249
|
+
INSERT INTO subscriptions (subscriptionId, expiresAt, feedPrivateIds)
|
|
250
|
+
VALUES (${subscriptionId}, ${expiresAt}, ${JSON.stringify(feedPrivateIds)})
|
|
251
|
+
`;
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
requestId: request.requestId,
|
|
255
|
+
subscriptionId,
|
|
256
|
+
expiresAt,
|
|
257
|
+
};
|
|
258
|
+
}),
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
append = Effect.fn('Feed.append')(
|
|
262
|
+
(request: AppendRequest): Effect.Effect<AppendResponse, SqlError.SqlError, SqlClient.SqlClient> =>
|
|
263
|
+
Effect.gen(this, function* () {
|
|
264
|
+
const sql = yield* SqlClient.SqlClient;
|
|
265
|
+
const positions: number[] = [];
|
|
266
|
+
|
|
267
|
+
if (!request.spaceId) {
|
|
268
|
+
return yield* Effect.die(new Error('spaceId required for append'));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
for (const block of request.blocks) {
|
|
272
|
+
const feedId = request.feedId ?? block.actorId;
|
|
273
|
+
const feedPrivateId = yield* this._ensureFeed(request.spaceId, feedId, request.namespace);
|
|
274
|
+
|
|
275
|
+
let nextPos = null;
|
|
276
|
+
if (this._options.assignPositions) {
|
|
277
|
+
const maxPosResult = yield* sql<{ maxPos: number | null }>`
|
|
278
|
+
SELECT MAX(position) as maxPos
|
|
279
|
+
FROM blocks
|
|
280
|
+
JOIN feeds ON blocks.feedPrivateId = feeds.feedPrivateId
|
|
281
|
+
WHERE feeds.spaceId = ${request.spaceId}
|
|
282
|
+
`;
|
|
283
|
+
nextPos = (maxPosResult[0]?.maxPos ?? -1) + 1;
|
|
284
|
+
positions.push(nextPos);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
yield* sql`
|
|
288
|
+
INSERT INTO blocks (
|
|
289
|
+
feedPrivateId, position, sequence, actorId,
|
|
290
|
+
predSequence, predActorId, timestamp, data
|
|
291
|
+
) VALUES (
|
|
292
|
+
${feedPrivateId}, ${nextPos}, ${block.sequence}, ${block.actorId},
|
|
293
|
+
${block.predSequence}, ${block.predActorId}, ${block.timestamp}, ${block.data}
|
|
294
|
+
) ON CONFLICT DO NOTHING
|
|
295
|
+
`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
this.onNewBlocks.emit();
|
|
299
|
+
|
|
300
|
+
return { requestId: request.requestId, positions };
|
|
301
|
+
}),
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
appendLocal = Effect.fn('Feed.appendLocal')(
|
|
305
|
+
(
|
|
306
|
+
messages: { spaceId: string; feedId: string; feedNamespace: string; data: Uint8Array }[],
|
|
307
|
+
): Effect.Effect<Block[], SqlError.SqlError, SqlClient.SqlClient> =>
|
|
308
|
+
Effect.gen(this, function* () {
|
|
309
|
+
const sql = yield* SqlClient.SqlClient;
|
|
310
|
+
const blocks: Block[] = [];
|
|
311
|
+
|
|
312
|
+
// Group by spaceId for efficient processing? Or just iterate.
|
|
313
|
+
// Assuming small batch.
|
|
314
|
+
|
|
315
|
+
for (const msg of messages) {
|
|
316
|
+
// Get last block for this feed to determine sequence and predecessor.
|
|
317
|
+
// Assumes we are the writer (localActorId).
|
|
318
|
+
// If we are not localActorId, we shouldn't be creating blocks this way?
|
|
319
|
+
// The user said: "uses local actorId". So feedId in message might be ignored?
|
|
320
|
+
// Or message provides feedId?
|
|
321
|
+
// User said: "client provides (only data + spaceId, feedId, feedNamespace)".
|
|
322
|
+
// If client provides feedId, does it match localActorId?
|
|
323
|
+
// If localActorId is fixed for the device, and we are appending to "my" feed, feedId should be localActorId.
|
|
324
|
+
// If the client explicitly passes feedId, maybe they want to write to a specific feed (e.g. if they have multiple identities?).
|
|
325
|
+
// But spec says "uses local actorId". I will use `this._options.localActorId`.
|
|
326
|
+
// But `messages` arg has `feedId`.
|
|
327
|
+
// I'll assume `msg.feedId` must match `localActorId` or we just use `localActorId` and ignore `msg.feedId`?
|
|
328
|
+
// User: "client provides ... feedId".
|
|
329
|
+
// I will use passed feedId but it should probably match localActorId if we are signing? (No signing here yet).
|
|
330
|
+
// I'll use passed `feedId`.
|
|
331
|
+
|
|
332
|
+
const feedPrivateId = yield* this._ensureFeed(msg.spaceId, msg.feedId, msg.feedNamespace);
|
|
333
|
+
|
|
334
|
+
// Get last block to determine sequence.
|
|
335
|
+
const lastBlockResult = yield* sql<{ sequence: number; actorId: string }>`
|
|
336
|
+
SELECT sequence, actorId FROM blocks
|
|
337
|
+
WHERE feedPrivateId = ${feedPrivateId}
|
|
338
|
+
ORDER BY sequence DESC
|
|
339
|
+
LIMIT 1
|
|
340
|
+
`; // This gets checks only THIS feed.
|
|
341
|
+
|
|
342
|
+
const lastBlock = lastBlockResult[0];
|
|
343
|
+
const sequence = (lastBlock?.sequence ?? -1) + 1;
|
|
344
|
+
const predSequence = lastBlock?.sequence ?? null;
|
|
345
|
+
const predActorId = lastBlock?.actorId ?? null;
|
|
346
|
+
|
|
347
|
+
const block: Block = {
|
|
348
|
+
feedId: undefined,
|
|
349
|
+
actorId: this._options.localActorId,
|
|
350
|
+
sequence,
|
|
351
|
+
predActorId,
|
|
352
|
+
predSequence,
|
|
353
|
+
timestamp: Date.now(),
|
|
354
|
+
data: msg.data,
|
|
355
|
+
position: null, // assigned by append
|
|
356
|
+
};
|
|
357
|
+
blocks.push(block);
|
|
358
|
+
|
|
359
|
+
// Call append to persist (and assign position if allowed).
|
|
360
|
+
yield* this.append({
|
|
361
|
+
requestId: 'local-append',
|
|
362
|
+
namespace: msg.feedNamespace,
|
|
363
|
+
blocks: [block],
|
|
364
|
+
spaceId: msg.spaceId,
|
|
365
|
+
feedId: msg.feedId,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
return blocks;
|
|
369
|
+
}),
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const encodeCursor = (token: string, insertionId: number) => FeedCursor.make(`${token}|${insertionId}`);
|
|
374
|
+
const decodeCursor = (cursor: FeedCursor) => {
|
|
375
|
+
const [token, insertionId] = cursor.split('|');
|
|
376
|
+
return { token, insertionId: Number(insertionId) };
|
|
377
|
+
};
|
package/src/index.ts
ADDED
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as Schema from 'effect/Schema';
|
|
6
|
+
|
|
7
|
+
export const FeedCursor = Schema.String.pipe(Schema.brand('@dxos/feed/FeedCursor'));
|
|
8
|
+
export type FeedCursor = Schema.Schema.Type<typeof FeedCursor>;
|
|
9
|
+
|
|
10
|
+
export const Block = Schema.Struct({
|
|
11
|
+
/**
|
|
12
|
+
* Only set on query.
|
|
13
|
+
*/
|
|
14
|
+
feedId: Schema.UndefinedOr(Schema.String),
|
|
15
|
+
|
|
16
|
+
actorId: Schema.String,
|
|
17
|
+
sequence: Schema.Number,
|
|
18
|
+
predActorId: Schema.NullOr(Schema.String),
|
|
19
|
+
predSequence: Schema.NullOr(Schema.Number),
|
|
20
|
+
position: Schema.NullOr(Schema.Number),
|
|
21
|
+
timestamp: Schema.Number,
|
|
22
|
+
data: Schema.Uint8Array,
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Local insertion ID.
|
|
26
|
+
* Not replicated.
|
|
27
|
+
*/
|
|
28
|
+
// TODO(dmaretskyi): Remove. Use cursors.
|
|
29
|
+
insertionId: Schema.optional(Schema.Number),
|
|
30
|
+
});
|
|
31
|
+
export interface Block extends Schema.Schema.Type<typeof Block> {}
|
|
32
|
+
|
|
33
|
+
//
|
|
34
|
+
// RPC Schemas
|
|
35
|
+
//
|
|
36
|
+
|
|
37
|
+
// Request is a union of query by subscription or query by feedIds
|
|
38
|
+
export const QueryRequest = Schema.Struct({
|
|
39
|
+
requestId: Schema.optional(Schema.String),
|
|
40
|
+
|
|
41
|
+
// TODO(dmaretskyi): Make required.
|
|
42
|
+
spaceId: Schema.optional(Schema.String),
|
|
43
|
+
|
|
44
|
+
query: Schema.Union(
|
|
45
|
+
Schema.Struct({
|
|
46
|
+
feedIds: Schema.Array(Schema.String),
|
|
47
|
+
}),
|
|
48
|
+
Schema.Struct({
|
|
49
|
+
subscriptionId: Schema.String,
|
|
50
|
+
}),
|
|
51
|
+
Schema.Struct({
|
|
52
|
+
feedNamespace: Schema.optional(Schema.String),
|
|
53
|
+
}),
|
|
54
|
+
),
|
|
55
|
+
/**
|
|
56
|
+
* Get changes following this cursor (exclusive).
|
|
57
|
+
*
|
|
58
|
+
* Must not be used with `position`.
|
|
59
|
+
*/
|
|
60
|
+
cursor: Schema.optional(FeedCursor),
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Get changes following this position.
|
|
64
|
+
*
|
|
65
|
+
* Must not be used with `cursor`.
|
|
66
|
+
*/
|
|
67
|
+
position: Schema.optional(Schema.Number),
|
|
68
|
+
|
|
69
|
+
limit: Schema.optional(Schema.Number),
|
|
70
|
+
});
|
|
71
|
+
export interface QueryRequest extends Schema.Schema.Type<typeof QueryRequest> {}
|
|
72
|
+
|
|
73
|
+
// Response is a stream/array of blocks
|
|
74
|
+
export const QueryResponse = Schema.Struct({
|
|
75
|
+
requestId: Schema.optional(Schema.String),
|
|
76
|
+
nextCursor: FeedCursor,
|
|
77
|
+
blocks: Schema.Array(Block),
|
|
78
|
+
});
|
|
79
|
+
export interface QueryResponse extends Schema.Schema.Type<typeof QueryResponse> {}
|
|
80
|
+
|
|
81
|
+
export const SubscribeRequest = Schema.Struct({
|
|
82
|
+
requestId: Schema.optional(Schema.String),
|
|
83
|
+
feedIds: Schema.Array(Schema.String),
|
|
84
|
+
spaceId: Schema.optional(Schema.String),
|
|
85
|
+
});
|
|
86
|
+
export interface SubscribeRequest extends Schema.Schema.Type<typeof SubscribeRequest> {}
|
|
87
|
+
|
|
88
|
+
export const SubscribeResponse = Schema.Struct({
|
|
89
|
+
requestId: Schema.optional(Schema.String),
|
|
90
|
+
subscriptionId: Schema.String,
|
|
91
|
+
expiresAt: Schema.Number,
|
|
92
|
+
});
|
|
93
|
+
export interface SubscribeResponse extends Schema.Schema.Type<typeof SubscribeResponse> {}
|
|
94
|
+
|
|
95
|
+
export const AppendRequest = Schema.Struct({
|
|
96
|
+
requestId: Schema.optional(Schema.String),
|
|
97
|
+
|
|
98
|
+
spaceId: Schema.String,
|
|
99
|
+
namespace: Schema.String,
|
|
100
|
+
feedId: Schema.String,
|
|
101
|
+
|
|
102
|
+
blocks: Schema.Array(Block),
|
|
103
|
+
});
|
|
104
|
+
export interface AppendRequest extends Schema.Schema.Type<typeof AppendRequest> {}
|
|
105
|
+
|
|
106
|
+
export const AppendResponse = Schema.Struct({
|
|
107
|
+
requestId: Schema.optional(Schema.String),
|
|
108
|
+
positions: Schema.Array(Schema.Number),
|
|
109
|
+
});
|
|
110
|
+
export interface AppendResponse extends Schema.Schema.Type<typeof AppendResponse> {}
|
package/src/sync.test.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import type * as SqlClient from '@effect/sql/SqlClient';
|
|
6
|
+
import * as SqliteClient from '@effect/sql-sqlite-node/SqliteClient';
|
|
7
|
+
import { describe, expect, it } from '@effect/vitest';
|
|
8
|
+
import * as Effect from 'effect/Effect';
|
|
9
|
+
import * as ManagedRuntime from 'effect/ManagedRuntime';
|
|
10
|
+
|
|
11
|
+
import { SpaceId } from '@dxos/keys';
|
|
12
|
+
|
|
13
|
+
import { FeedStore } from './feed-store';
|
|
14
|
+
import { type Block } from './protocol';
|
|
15
|
+
|
|
16
|
+
describe.skip('Feed Sync V2 (RPC)', () => {
|
|
17
|
+
const makePeer = () => {
|
|
18
|
+
const layer = SqliteClient.layer({ filename: ':memory:' });
|
|
19
|
+
const runtime = ManagedRuntime.make(layer);
|
|
20
|
+
const run = <A, E>(effect: Effect.Effect<A, E, SqlClient.SqlClient>) => runtime.runPromise(effect);
|
|
21
|
+
return { run };
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
it('should sync blocks from server to client via RPC', async () => {
|
|
25
|
+
const server = makePeer();
|
|
26
|
+
const client = makePeer();
|
|
27
|
+
|
|
28
|
+
const spaceId = SpaceId.random();
|
|
29
|
+
const feedId = '01H1V1X1X1X1X1X1X1X1X1X1X3';
|
|
30
|
+
|
|
31
|
+
// Server setup: Append blocks
|
|
32
|
+
await server.run(
|
|
33
|
+
Effect.gen(function* () {
|
|
34
|
+
const feed = new FeedStore({ localActorId: 'server', assignPositions: true });
|
|
35
|
+
const blocks: Block[] = [
|
|
36
|
+
{
|
|
37
|
+
feedId: undefined,
|
|
38
|
+
actorId: feedId,
|
|
39
|
+
sequence: 1,
|
|
40
|
+
predActorId: null,
|
|
41
|
+
predSequence: null,
|
|
42
|
+
position: null,
|
|
43
|
+
timestamp: 100,
|
|
44
|
+
data: new Uint8Array([1]),
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
feedId: undefined,
|
|
48
|
+
actorId: feedId,
|
|
49
|
+
sequence: 2,
|
|
50
|
+
predActorId: feedId,
|
|
51
|
+
predSequence: 1,
|
|
52
|
+
position: null,
|
|
53
|
+
timestamp: 200,
|
|
54
|
+
data: new Uint8Array([2]),
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
yield* feed.append({ requestId: 'req-0', blocks, spaceId, namespace: 'data', feedId });
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
// Sync Simulation: Client polls Server
|
|
62
|
+
// 1. Client subscribes
|
|
63
|
+
const subId = await server.run(
|
|
64
|
+
Effect.gen(function* () {
|
|
65
|
+
// Wait, we flattened it. Tests need to update to NOT use SqlFeedStore!
|
|
66
|
+
// I will fix the class instantiation in a moment.
|
|
67
|
+
const feed = new FeedStore({ localActorId: 'server', assignPositions: true });
|
|
68
|
+
const res = yield* feed.subscribe({ requestId: 'req-sub', feedIds: [feedId], spaceId });
|
|
69
|
+
return res.subscriptionId;
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
// 2. Client queries using subscription
|
|
74
|
+
const blocks = await server.run(
|
|
75
|
+
Effect.gen(function* () {
|
|
76
|
+
const feed = new FeedStore({ localActorId: 'server', assignPositions: true }); // Updated class usage
|
|
77
|
+
const res = yield* feed.query({ requestId: 'req-query', query: { subscriptionId: subId }, position: 0 });
|
|
78
|
+
return res.blocks;
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
expect(blocks.length).toBe(2);
|
|
83
|
+
expect(blocks[0].sequence).toBe(1);
|
|
84
|
+
|
|
85
|
+
// 3. Client stores them (Verify client persistence)
|
|
86
|
+
await client.run(
|
|
87
|
+
Effect.gen(function* () {
|
|
88
|
+
const feed = new FeedStore({ localActorId: 'client', assignPositions: true }); // Updated class usage
|
|
89
|
+
// Client appends them.
|
|
90
|
+
yield* feed.append({ requestId: 'req-push', blocks, spaceId, namespace: 'data', feedId });
|
|
91
|
+
|
|
92
|
+
// Verify
|
|
93
|
+
const myBlocks = yield* feed.query({
|
|
94
|
+
requestId: 'req-verify',
|
|
95
|
+
query: { feedIds: [feedId] },
|
|
96
|
+
position: 0,
|
|
97
|
+
spaceId,
|
|
98
|
+
});
|
|
99
|
+
expect(myBlocks.blocks.length).toBe(2);
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
});
|