@pdsjs/spaces 1.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/src/writer.js ADDED
@@ -0,0 +1,195 @@
1
+ // @pdsjs/spaces/writer - the single write path for permissioned repos.
2
+ //
3
+ // createRecord, putRecord, deleteRecord and applyWrites all funnel through
4
+ // applyWrites here: one commit, one rev, one LtHash advance, one atomic
5
+ // commitSpaceWrite. Nothing else mutates a permissioned repo.
6
+
7
+ import {
8
+ cborEncodeDagCbor,
9
+ cidToString,
10
+ createCid,
11
+ createTid,
12
+ } from '@pdsjs/core/repo';
13
+ import { RepoCommit } from './commit.js';
14
+ import { formatRecordPath } from './path.js';
15
+ import { makeSpaceRow } from './space-row.js';
16
+
17
+ export const MAX_WRITES_PER_COMMIT = 200;
18
+
19
+ export class SpaceWriteError extends Error {
20
+ /**
21
+ * @param {string} message
22
+ * @param {string} code - machine-readable error name for the XRPC response
23
+ */
24
+ constructor(message, code) {
25
+ super(message);
26
+ this.name = 'SpaceWriteError';
27
+ this.code = code;
28
+ }
29
+ }
30
+
31
+ export class SpaceRecordNotFoundError extends SpaceWriteError {
32
+ /** @param {string} collection @param {string} rkey */
33
+ constructor(collection, rkey) {
34
+ super(`Record not found: ${collection}/${rkey}`, 'RecordNotFound');
35
+ }
36
+ }
37
+
38
+ export class SpaceRecordAlreadyExistsError extends SpaceWriteError {
39
+ /** @param {string} collection @param {string} rkey */
40
+ constructor(collection, rkey) {
41
+ super(
42
+ `Record already exists: ${collection}/${rkey}`,
43
+ 'RecordAlreadyExists',
44
+ );
45
+ }
46
+ }
47
+
48
+ // Commits for one space must not interleave: applyWrites reads the current set
49
+ // hash and the touched records' CIDs, then commits a state derived from them.
50
+ // Two concurrent commits would each fold their ops into the same starting hash
51
+ // and the last write would drop the other's contribution. The port's commit is
52
+ // atomic but cannot serialise across calls, so serialise here — sound because a
53
+ // permissioned repo lives in exactly one process (a Node PDS, or one Durable
54
+ // Object).
55
+ /** @type {WeakMap<object, Map<string, Promise<any>>>} */
56
+ const spaceQueues = new WeakMap();
57
+
58
+ /**
59
+ * @template T
60
+ * @param {object} storage
61
+ * @param {string} space
62
+ * @param {() => Promise<T>} fn
63
+ * @returns {Promise<T>}
64
+ */
65
+ function serialize(storage, space, fn) {
66
+ let queues = spaceQueues.get(storage);
67
+ if (!queues) {
68
+ queues = new Map();
69
+ spaceQueues.set(storage, queues);
70
+ }
71
+ const prior = queues.get(space) ?? Promise.resolve();
72
+ // Chain off the prior commit's settlement, not its value, so one failed
73
+ // commit does not poison every later write to the same space.
74
+ const next = prior.then(fn, fn);
75
+ queues.set(
76
+ space,
77
+ next.then(
78
+ () => undefined,
79
+ () => undefined,
80
+ ),
81
+ );
82
+ return next;
83
+ }
84
+
85
+ /**
86
+ * @typedef {Object} SpaceWriteInput
87
+ * @property {'create'|'update'|'put'|'delete'} action
88
+ * @property {string} collection
89
+ * @property {string} rkey
90
+ * @property {Object} [record] - required for everything but delete
91
+ */
92
+
93
+ /**
94
+ * @typedef {Object} SpaceWriteResult
95
+ * @property {'create'|'update'|'delete'} action
96
+ * @property {string} collection
97
+ * @property {string} rkey
98
+ * @property {string|null} cid - null for deletes
99
+ * @property {string|null} prev - null for creates
100
+ */
101
+
102
+ /**
103
+ * Apply a batch of writes to a permissioned repo as one commit.
104
+ *
105
+ * Writes resolve in order against the state the batch has built up, not just
106
+ * what was stored on entry, so a batch can create then update a record and a
107
+ * repeated create is still caught rather than double-counted in the set hash.
108
+ *
109
+ * @param {import('@pdsjs/core/ports').SpaceStoragePort} storage
110
+ * @param {Object} opts
111
+ * @param {string} opts.space - space AT-URI
112
+ * @param {SpaceWriteInput[]} opts.writes
113
+ * @param {string} [opts.now] - ISO timestamp, for deterministic tests
114
+ * @returns {Promise<{rev: string, setHash: Uint8Array, results: SpaceWriteResult[]}>}
115
+ */
116
+ export async function applyWrites(storage, { space, writes, now }) {
117
+ if (writes.length > MAX_WRITES_PER_COMMIT) {
118
+ throw new SpaceWriteError(
119
+ `Too many writes. Max: ${MAX_WRITES_PER_COMMIT}`,
120
+ 'InvalidRequest',
121
+ );
122
+ }
123
+
124
+ return serialize(storage, space, async () => {
125
+ const timestamp = now ?? new Date().toISOString();
126
+
127
+ // A writer's PDS is never told about membership — the member list is the
128
+ // authority's concern — so it materialises its own repo on first write.
129
+ if ((await storage.getSpace(space)) === null) {
130
+ await storage.putSpace(
131
+ makeSpaceRow(space, { isOwner: false, createdAt: timestamp }),
132
+ );
133
+ }
134
+
135
+ const state = await storage.getSpaceRepo(space);
136
+ const repo = RepoCommit.fromState(state?.setHash ?? null);
137
+ const rev = createTid();
138
+
139
+ /** @type {Map<string, string|null>} live cid per touched path; null = deleted */
140
+ const staged = new Map();
141
+ /** @type {SpaceWriteResult[]} */
142
+ const results = [];
143
+ /** @type {import('@pdsjs/core/ports').SpaceWrite[]} */
144
+ const portWrites = [];
145
+
146
+ for (const write of writes) {
147
+ const { collection, rkey } = write;
148
+ const key = formatRecordPath(collection, rkey);
149
+ const prev = staged.has(key)
150
+ ? /** @type {string|null} */ (staged.get(key))
151
+ : await storage.getSpaceRecordCid(space, collection, rkey);
152
+
153
+ if (write.action === 'create' && prev) {
154
+ throw new SpaceRecordAlreadyExistsError(collection, rkey);
155
+ }
156
+ if ((write.action === 'update' || write.action === 'delete') && !prev) {
157
+ throw new SpaceRecordNotFoundError(collection, rkey);
158
+ }
159
+
160
+ if (write.action === 'delete') {
161
+ repo.applyOp({ collection, rkey, cid: null, prev });
162
+ staged.set(key, null);
163
+ results.push({ action: 'delete', collection, rkey, cid: null, prev });
164
+ portWrites.push({
165
+ action: 'delete',
166
+ collection,
167
+ rkey,
168
+ cid: null,
169
+ value: null,
170
+ prev,
171
+ });
172
+ continue;
173
+ }
174
+
175
+ const value = cborEncodeDagCbor(write.record);
176
+ const cid = cidToString(await createCid(value));
177
+ repo.applyOp({ collection, rkey, cid, prev });
178
+ staged.set(key, cid);
179
+
180
+ const action = prev ? 'update' : 'create';
181
+ results.push({ action, collection, rkey, cid, prev });
182
+ portWrites.push({ action, collection, rkey, cid, value, prev });
183
+ }
184
+
185
+ const setHash = repo.state();
186
+ await storage.commitSpaceWrite(space, {
187
+ writes: portWrites,
188
+ setHash,
189
+ rev,
190
+ indexedAt: timestamp,
191
+ });
192
+
193
+ return { rev, setHash, results };
194
+ });
195
+ }