@astrosheep/square 0.3.45 → 0.3.46

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "square",
3
- "version": "0.3.45",
3
+ "version": "0.3.46",
4
4
  "description": "Native Claude Code turn-boundary delivery for Square participants",
5
5
  "author": {
6
6
  "name": "Square"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "square",
3
- "version": "0.3.45",
3
+ "version": "0.3.46",
4
4
  "description": "Shared Square activity with reliable participant attention at Codex boundaries.",
5
5
  "author": {
6
6
  "name": "Square"
@@ -1,4 +1,5 @@
1
- import { type BuildOptions, type HardCap, type SquareState, type SquareRuntimeState } from './model.js';
1
+ import { type SquareState } from './model.js';
2
+ export { createSquareState, emptyRuntimeState } from './square-state.js';
2
3
  export interface DoctorProblem {
3
4
  kind: string;
4
5
  message: string;
@@ -8,12 +9,32 @@ export interface DiagnoseResult {
8
9
  problems: DoctorProblem[];
9
10
  state?: SquareState;
10
11
  }
11
- export declare function emptyRuntimeState(nextActIndex?: number): SquareRuntimeState;
12
- export declare function createSquareState(options: BuildOptions & {
13
- hardCap: HardCap;
14
- }, snippet: string): SquareState;
15
- export declare function encodeSquare(squareState: SquareState): Buffer;
16
- export declare function decodeSquare(bytes: Buffer): SquareState;
12
+ export interface SquareSnapshot {
13
+ readonly state: SquareState;
14
+ readonly revision: number;
15
+ }
16
+ /** Creates a new SQLite artifact only when its pathname did not already exist. */
17
+ export declare function createSquareFile(squarePath: string, state: SquareState): Promise<boolean>;
18
+ /**
19
+ * Reads a complete validated snapshot, yielding between transient SQLite lock
20
+ * conflicts. The URI remains create-disabled throughout the retry loop.
21
+ */
22
+ export declare function readSquareSnapshot(squarePath: string, signal?: AbortSignal): Promise<SquareSnapshot>;
23
+ export type SquareTransition<R> = (state: SquareState, revision: number) => {
24
+ state?: SquareState;
25
+ result: R;
26
+ };
27
+ /**
28
+ * Executes one synchronous SquareState transition under SQLite's write lock.
29
+ * A busy COMMIT retries on this same transaction, so fn is never replayed.
30
+ */
31
+ export declare function transactSquareSnapshot<R>(squarePath: string, fn: SquareTransition<R>, signal?: AbortSignal, forceCommit?: boolean): Promise<{
32
+ result: R;
33
+ revision: number;
34
+ changed: boolean;
35
+ }>;
36
+ /** Replaces an existing validated snapshot in place and always advances its revision. */
37
+ export declare function replaceSquareSnapshot(squarePath: string, state: SquareState): Promise<SquareSnapshot>;
17
38
  export declare function writeSquareFile(squarePath: string, squareState: SquareState): Promise<void>;
18
39
  export declare function loadSquare(squarePath: string): Promise<SquareState>;
19
40
  export declare function probeSquare(squarePath: string): Promise<SquareState | undefined>;
package/dist/artifact.js CHANGED
@@ -1,317 +1,360 @@
1
- import crypto from 'node:crypto';
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { randomUUID } from 'node:crypto';
2
3
  import fs from 'node:fs';
3
4
  import path from 'node:path';
4
- import { TextDecoder } from 'node:util';
5
- import zlib from 'node:zlib';
6
- import { isWakeRouteKind, InternalSquareError, SquareError, } from './model.js';
7
- import { parseActivityId } from './square-core.js';
8
- const SQUARE_MAGIC = Buffer.from('SQUARE01', 'ascii');
9
- const LENGTH_BYTES = 4;
10
- const DIGEST_BYTES = 32;
11
- const HEADER_BYTES = SQUARE_MAGIC.length + LENGTH_BYTES + DIGEST_BYTES;
12
- const utf8 = new TextDecoder('utf-8', { fatal: true });
13
- const guideNames = ['participant', 'architect', 'brainstorm'];
14
- const guideContents = new Map(await Promise.all(guideNames.map(async (name) => [name, (await fs.promises.readFile(new URL(`../guides/${name}.md`, import.meta.url), 'utf8')).trim()])));
5
+ import { pathToFileURL } from 'node:url';
6
+ import { setTimeout as sleep } from 'node:timers/promises';
7
+ import { isDeepStrictEqual } from 'node:util';
8
+ import { InternalSquareError, SquareError } from './model.js';
9
+ import { validateSquareState } from './square-state.js';
10
+ const APPLICATION_ID = 0x53515245;
11
+ const USER_VERSION = 1;
12
+ const SNAPSHOT_TABLE = 'square_snapshot';
13
+ const SNAPSHOT_DDL = `CREATE TABLE ${SNAPSHOT_TABLE} (id INTEGER PRIMARY KEY CHECK(id=1), revision INTEGER NOT NULL CHECK(revision>=0), state TEXT NOT NULL)`;
14
+ const SNAPSHOT_COLUMNS = [
15
+ { cid: 0, name: 'id', type: 'INTEGER', notnull: 0, dflt_value: null, pk: 1, hidden: 0 },
16
+ { cid: 1, name: 'revision', type: 'INTEGER', notnull: 1, dflt_value: null, pk: 0, hidden: 0 },
17
+ { cid: 2, name: 'state', type: 'TEXT', notnull: 1, dflt_value: null, pk: 0, hidden: 0 },
18
+ ];
19
+ export { createSquareState, emptyRuntimeState } from './square-state.js';
15
20
  function invalidArtifact(detail) {
16
21
  return new SquareError('invalid_args', `Invalid square artifact: ${detail}`);
17
22
  }
18
- function isObject(value) {
19
- return typeof value === 'object' && value !== null && !Array.isArray(value);
20
- }
21
- function hasExactKeys(value, required, optional = []) {
22
- const allowed = new Set([...required, ...optional]);
23
- return required.every((key) => Object.hasOwn(value, key))
24
- && Object.keys(value).every((key) => allowed.has(key));
25
- }
26
- function isFiniteNumber(value) {
27
- return typeof value === 'number' && Number.isFinite(value);
28
- }
29
- function isNonNegativeInteger(value) {
30
- return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
31
- }
32
- function isNonblankString(value) {
33
- return typeof value === 'string' && value.length > 0;
23
+ function requireSquareExtension(squarePath) {
24
+ if (!squarePath.endsWith('.square'))
25
+ throw new SquareError('invalid_args', `Square artifacts must use the .square extension: ${squarePath}`);
34
26
  }
35
- function isStringArray(value) {
36
- return Array.isArray(value) && value.every((item) => typeof item === 'string');
27
+ function isNotFound(error) {
28
+ return error instanceof Error && /unable to open database file|no such file|SQLITE_CANTOPEN/i.test(error.message);
37
29
  }
38
- function validateObservation(value) {
39
- return isObject(value)
40
- && hasExactKeys(value, ['state', 'at'])
41
- && value.state === 'seen'
42
- && isFiniteNumber(value.at);
30
+ function isNotADatabase(error) {
31
+ return error instanceof Error && /not a database|file is not a database|SQLITE_NOTADB/i.test(error.message);
43
32
  }
44
- function validateWatchLease(value) {
45
- if (!isObject(value)
46
- || !hasExactKeys(value, ['leaseId', 'heartbeatAt', 'expiresAt'], ['filter'])
47
- || !isNonblankString(value.leaseId)
48
- || !isFiniteNumber(value.heartbeatAt)
49
- || !isFiniteNumber(value.expiresAt)
50
- || value.expiresAt < value.heartbeatAt)
51
- return false;
52
- if (value.filter === undefined)
53
- return true;
54
- return isObject(value.filter)
55
- && hasExactKeys(value.filter, [], ['participants', 'mention'])
56
- && (value.filter.participants === undefined || isStringArray(value.filter.participants))
57
- && (value.filter.mention === undefined || typeof value.filter.mention === 'string');
33
+ function existingDatabaseUri(squarePath) {
34
+ const uri = pathToFileURL(path.resolve(squarePath));
35
+ uri.searchParams.set('mode', 'rw');
36
+ return uri.href;
58
37
  }
59
- function validateRecord(value, item) {
60
- return isObject(value)
61
- && Object.entries(value).every(([key, candidate]) => key.length > 0 && item(candidate));
38
+ function openExistingDatabase(squarePath) {
39
+ try {
40
+ return new DatabaseSync(existingDatabaseUri(squarePath));
41
+ }
42
+ catch (error) {
43
+ if (isNotFound(error))
44
+ throw new InternalSquareError('not_found', `square file not found: ${squarePath}`);
45
+ if (isNotADatabase(error))
46
+ throw invalidArtifact('not a SQLite database.');
47
+ throw error;
48
+ }
62
49
  }
63
- function validateRuntime(value) {
64
- if (!isObject(value)
65
- || !hasExactKeys(value, ['nextActIndex', 'observations', 'leases'])
66
- || !isNonNegativeInteger(value.nextActIndex)
67
- || !validateRecord(value.observations, (candidate) => isObject(candidate) && Object.entries(candidate).every(([id, observation]) => parseActivityId(id) !== undefined && validateObservation(observation)))
68
- || !validateRecord(value.leases, validateWatchLease))
69
- return false;
70
- return true;
50
+ function closeQuietly(database) {
51
+ try {
52
+ database?.close();
53
+ }
54
+ catch { /* closing does not alter the primary failure */ }
71
55
  }
72
- function validateAssignedRuntimeReferences(runtime) {
73
- const bound = runtime.nextActIndex;
74
- for (const observations of Object.values(runtime.observations)) {
75
- for (const id of Object.keys(observations)) {
76
- const index = parseActivityId(id);
77
- if (index === undefined)
78
- return 'malformed';
79
- if (index >= bound)
80
- return 'future';
81
- }
56
+ function parseState(state) {
57
+ if (typeof state !== 'string')
58
+ throw invalidArtifact('snapshot state is malformed.');
59
+ try {
60
+ return validateSquareState(JSON.parse(state));
61
+ }
62
+ catch (error) {
63
+ if (error instanceof SquareError)
64
+ throw error;
65
+ throw invalidArtifact('snapshot state is not valid JSON.');
82
66
  }
83
- return 'ok';
84
67
  }
85
- function validateActor(value, required) {
86
- return required ? isNonblankString(value) : value === undefined || isNonblankString(value);
68
+ function normalizedSql(sql) {
69
+ return sql.trim().replace(/\s+/g, ' ').replace(/\s*(>=|[(),=])\s*/g, '$1');
87
70
  }
88
- function validateStoredAct(value) {
89
- if (!isObject(value)
90
- || typeof value.kind !== 'string'
91
- || !isNonNegativeInteger(value.index)
92
- || !isFiniteNumber(value.at))
71
+ function isOwnedSnapshotSchema(database) {
72
+ const objects = database.prepare("SELECT type, name, tbl_name, sql FROM sqlite_schema WHERE name NOT GLOB 'sqlite_*' ORDER BY type, name")
73
+ .all();
74
+ if (objects.length !== 1)
93
75
  return false;
94
- switch (value.kind) {
95
- case 'join':
96
- return hasExactKeys(value, ['kind', 'actor', 'at', 'index']) && validateActor(value.actor, true);
97
- case 'done':
98
- return hasExactKeys(value, ['kind', 'actor', 'at', 'index'], ['body'])
99
- && validateActor(value.actor, true)
100
- && (value.body === undefined || typeof value.body === 'string');
101
- case 'say':
102
- return hasExactKeys(value, ['kind', 'actor', 'at', 'body', 'index'], ['mentions', 'reach', 'reply'])
103
- && validateActor(value.actor, true)
104
- && typeof value.body === 'string'
105
- && (value.mentions === undefined || isStringArray(value.mentions))
106
- && (value.reach === undefined || value.reach === 'bell')
107
- && (value.reply === undefined || isNonNegativeInteger(value.reply));
108
- case 'hold':
109
- return hasExactKeys(value, ['kind', 'at', 'index'], ['actor', 'body'])
110
- && validateActor(value.actor, false)
111
- && (value.body === undefined || typeof value.body === 'string');
112
- case 'resume':
113
- return hasExactKeys(value, ['kind', 'at', 'index'], ['actor']) && validateActor(value.actor, false);
114
- case 'read':
115
- return hasExactKeys(value, ['kind', 'actor', 'at', 'through', 'index'])
116
- && validateActor(value.actor, true)
117
- && isNonNegativeInteger(value.through);
118
- case 'listen':
119
- case 'ignore':
120
- return hasExactKeys(value, ['kind', 'actor', 'target', 'at', 'index'])
121
- && validateActor(value.actor, true)
122
- && validateActor(value.target, true);
123
- default:
124
- return false;
125
- }
126
- }
127
- function validateActs(value) {
128
- if (!Array.isArray(value) || !value.every(validateStoredAct))
76
+ const [object] = objects;
77
+ if (object.type !== 'table' || object.name !== SNAPSHOT_TABLE || object.tbl_name !== SNAPSHOT_TABLE
78
+ || typeof object.sql !== 'string' || normalizedSql(object.sql) !== normalizedSql(SNAPSHOT_DDL))
129
79
  return false;
130
- let previous = -1;
131
- for (const act of value) {
132
- if (act.index <= previous)
133
- return false;
134
- if (act.kind === 'say' && act.reply !== undefined && act.reply >= act.index)
135
- return false;
136
- previous = act.index;
137
- }
138
- return true;
80
+ const columns = database.prepare('SELECT cid, name, type, "notnull", dflt_value, pk, hidden FROM pragma_table_xinfo(?) ORDER BY cid')
81
+ .all(SNAPSHOT_TABLE);
82
+ return columns.length === SNAPSHOT_COLUMNS.length && columns.every((column, index) => {
83
+ const expected = SNAPSHOT_COLUMNS[index];
84
+ return column.cid === expected.cid && column.name === expected.name && column.type === expected.type
85
+ && column.notnull === expected.notnull && column.dflt_value === expected.dflt_value
86
+ && column.pk === expected.pk && column.hidden === expected.hidden;
87
+ });
139
88
  }
140
- function validateSquareState(value) {
141
- if (!isObject(value)
142
- || !hasExactKeys(value, ['hardCap', 'preamble', 'warmup', 'acts', 'runtime'], ['throttlePerMinute', 'routes'])
143
- || !(value.hardCap === null || (Number.isSafeInteger(value.hardCap) && value.hardCap > 0))
144
- || (value.throttlePerMinute !== undefined
145
- && (!Number.isSafeInteger(value.throttlePerMinute) || value.throttlePerMinute <= 0))
146
- || !isStringArray(value.preamble)
147
- || !isStringArray(value.warmup)
148
- || !validateActs(value.acts)
149
- || (value.routes !== undefined && (!Array.isArray(value.routes) || !value.routes.every((route) => isObject(route) && isWakeRouteKind(route.kind) && typeof route.location === 'string' && typeof route.participant === 'string' && typeof route.sessionId === 'string' && typeof route.channel === 'string' && isObject(route.address) && Object.values(route.address).every((item) => typeof item === 'string') && typeof route.updatedAt === 'number')))
150
- || !validateRuntime(value.runtime)) {
151
- throw invalidArtifact('snapshot schema is malformed.');
89
+ function validateDatabase(database) {
90
+ try {
91
+ const identity = database.prepare('SELECT application_id, user_version FROM pragma_application_id, pragma_user_version').get();
92
+ if (identity.application_id !== APPLICATION_ID)
93
+ throw invalidArtifact('unrelated SQLite database.');
94
+ if (identity.user_version !== USER_VERSION)
95
+ throw invalidArtifact('unsupported SQLite format version.');
96
+ if (!isOwnedSnapshotSchema(database))
97
+ throw invalidArtifact('unsupported snapshot schema.');
98
+ const rows = database.prepare(`SELECT id, revision, state FROM ${SNAPSHOT_TABLE}`).all();
99
+ if (rows.length !== 1 || rows[0].id !== 1 || !Number.isSafeInteger(rows[0].revision) || rows[0].revision < 0)
100
+ throw invalidArtifact('snapshot row is malformed.');
101
+ return { state: parseState(rows[0].state), revision: rows[0].revision };
152
102
  }
153
- const acts = value.acts;
154
- const runtime = value.runtime;
155
- const historyBoundary = acts.at(-1)?.index ?? -1;
156
- if (runtime.nextActIndex <= historyBoundary) {
157
- throw invalidArtifact('nextActIndex is behind the activity history.');
103
+ catch (error) {
104
+ if (error instanceof SquareError)
105
+ throw error;
106
+ if (isNotADatabase(error))
107
+ throw invalidArtifact('not a SQLite database.');
108
+ throw error;
158
109
  }
159
- const references = validateAssignedRuntimeReferences(runtime);
160
- if (references === 'malformed')
161
- throw invalidArtifact('snapshot schema is malformed.');
162
- if (references === 'future')
163
- throw invalidArtifact('runtime references an unassigned activity index.');
164
- return value;
165
110
  }
166
- function encodeEnvelope(magic, value) {
167
- const json = Buffer.from(JSON.stringify(value), 'utf8');
168
- const payload = zlib.gzipSync(json, { level: 9 });
169
- if (payload.length > 0xffff_ffff)
170
- throw invalidArtifact('compressed payload is too large.');
171
- const header = Buffer.alloc(HEADER_BYTES);
172
- magic.copy(header, 0);
173
- header.writeUInt32BE(payload.length, magic.length);
174
- crypto.createHash('sha256').update(payload).digest().copy(header, magic.length + LENGTH_BYTES);
175
- return Buffer.concat([header, payload]);
111
+ function configureDatabase(database) {
112
+ database.exec('PRAGMA busy_timeout = 0; PRAGMA journal_mode = DELETE; PRAGMA synchronous = FULL;');
176
113
  }
177
- function decodeEnvelope(bytes, magic) {
178
- if (bytes.length < HEADER_BYTES)
179
- throw invalidArtifact('truncated header.');
180
- if (!bytes.subarray(0, magic.length).equals(magic))
181
- throw invalidArtifact('bad magic or unsupported format version.');
182
- const length = bytes.readUInt32BE(magic.length);
183
- if (bytes.length !== HEADER_BYTES + length)
184
- throw invalidArtifact('payload length does not match the file.');
185
- const expected = bytes.subarray(magic.length + LENGTH_BYTES, HEADER_BYTES);
186
- const payload = bytes.subarray(HEADER_BYTES);
187
- const actual = crypto.createHash('sha256').update(payload).digest();
188
- if (!crypto.timingSafeEqual(expected, actual))
189
- throw invalidArtifact('payload digest mismatch.');
190
- let inflated;
114
+ /** Opens an existing artifact without permitting SQLite to create a missing path. */
115
+ function readSquareSnapshotOnce(squarePath) {
116
+ requireSquareExtension(squarePath);
117
+ let database;
191
118
  try {
192
- inflated = zlib.gunzipSync(payload);
119
+ database = openExistingDatabase(squarePath);
120
+ return validateDatabase(database);
193
121
  }
194
- catch {
195
- throw invalidArtifact('payload is not valid gzip data.');
122
+ catch (error) {
123
+ if (error instanceof SquareError)
124
+ throw error;
125
+ if (isNotFound(error))
126
+ throw new InternalSquareError('not_found', `square file not found: ${squarePath}`);
127
+ if (isNotADatabase(error))
128
+ throw invalidArtifact('not a SQLite database.');
129
+ throw error;
196
130
  }
197
- let text;
198
- try {
199
- text = utf8.decode(inflated);
131
+ finally {
132
+ closeQuietly(database);
200
133
  }
201
- catch {
202
- throw invalidArtifact('payload is not valid UTF-8.');
134
+ }
135
+ async function createTemporaryArtifact(squarePath) {
136
+ const directory = path.dirname(squarePath);
137
+ await fs.promises.mkdir(directory, { recursive: true });
138
+ while (true) {
139
+ const temporary = path.join(directory, `.${path.basename(squarePath)}.${process.pid}.${randomUUID()}.tmp`);
140
+ try {
141
+ const handle = await fs.promises.open(temporary, 'wx');
142
+ await handle.close();
143
+ return temporary;
144
+ }
145
+ catch (error) {
146
+ if (error.code === 'EEXIST')
147
+ continue;
148
+ throw error;
149
+ }
203
150
  }
151
+ }
152
+ async function syncTemporaryArtifact(temporary) {
153
+ const handle = await fs.promises.open(temporary, 'r');
204
154
  try {
205
- return JSON.parse(text);
155
+ await handle.sync();
206
156
  }
207
- catch {
208
- throw invalidArtifact('payload is not valid JSON.');
157
+ finally {
158
+ await handle.close();
209
159
  }
210
160
  }
211
- export function emptyRuntimeState(nextActIndex = 0) {
212
- return {
213
- nextActIndex,
214
- observations: {},
215
- leases: {},
216
- };
217
- }
218
- function normalizedLines(value) {
219
- const normalized = value.replace(/\r\n/g, '\n').trim();
220
- return normalized === '' ? [] : normalized.split('\n');
161
+ async function removeTemporaryArtifact(temporary) {
162
+ try {
163
+ await fs.promises.unlink(temporary);
164
+ }
165
+ catch { /* cleanup cannot invalidate a published artifact */ }
221
166
  }
222
- function readGuide(name) {
167
+ function initializeDatabase(squarePath, state) {
168
+ const encoded = JSON.stringify(state);
169
+ let database;
223
170
  try {
224
- const guide = guideContents.get(name);
225
- if (guide === undefined)
226
- throw Object.assign(new Error('missing guide'), { code: 'ENOENT' });
227
- return guide;
171
+ database = openExistingDatabase(squarePath);
172
+ database.exec(`PRAGMA journal_mode = DELETE; PRAGMA synchronous = FULL; BEGIN IMMEDIATE; PRAGMA application_id = ${APPLICATION_ID}; PRAGMA user_version = ${USER_VERSION}; ${SNAPSHOT_DDL};`);
173
+ database.prepare(`INSERT INTO ${SNAPSHOT_TABLE} (id, revision, state) VALUES (1, 0, ?)`).run(encoded);
174
+ database.exec('COMMIT;');
228
175
  }
229
176
  catch (error) {
230
- if (error.code === 'ENOENT') {
231
- throw new SquareError('invalid_args', `Unknown square guide: ${name}`);
177
+ try {
178
+ database?.exec('ROLLBACK;');
232
179
  }
180
+ catch { /* retain initialization failure */ }
233
181
  throw error;
234
182
  }
183
+ finally {
184
+ closeQuietly(database);
185
+ }
186
+ }
187
+ /** Creates a new SQLite artifact only when its pathname did not already exist. */
188
+ export async function createSquareFile(squarePath, state) {
189
+ requireSquareExtension(squarePath);
190
+ const validated = validateSquareState(state);
191
+ const temporary = await createTemporaryArtifact(squarePath);
192
+ try {
193
+ initializeDatabase(temporary, validated);
194
+ await syncTemporaryArtifact(temporary);
195
+ try {
196
+ await fs.promises.link(temporary, squarePath);
197
+ return true;
198
+ }
199
+ catch (error) {
200
+ if (error.code === 'EEXIST')
201
+ return false;
202
+ throw error;
203
+ }
204
+ }
205
+ finally {
206
+ await removeTemporaryArtifact(temporary);
207
+ }
235
208
  }
236
- export function createSquareState(options, snippet) {
237
- const guides = [readGuide('participant')];
238
- if (options.template !== undefined)
239
- guides.push(readGuide(options.template));
240
- return {
241
- hardCap: options.hardCap,
242
- ...(options.throttlePerMinute === undefined ? {} : { throttlePerMinute: options.throttlePerMinute }),
243
- preamble: normalizedLines(snippet),
244
- warmup: normalizedLines(guides.join('\n\n')),
245
- acts: [],
246
- routes: [],
247
- runtime: emptyRuntimeState(),
248
- };
209
+ function isBusy(error) {
210
+ return error instanceof Error && /database is locked|database is busy|SQLITE_BUSY/i.test(error.message);
249
211
  }
250
- export function encodeSquare(squareState) {
251
- return encodeEnvelope(SQUARE_MAGIC, validateSquareState(squareState));
212
+ function isThenable(value) {
213
+ return (typeof value === 'object' || typeof value === 'function') && value !== null
214
+ && typeof value.then === 'function';
252
215
  }
253
- export function decodeSquare(bytes) {
254
- return validateSquareState(decodeEnvelope(bytes, SQUARE_MAGIC));
216
+ function discardThenable(value) {
217
+ void Promise.resolve(value).catch(() => undefined);
255
218
  }
256
- async function readArtifact(squarePath) {
219
+ function closedError() {
220
+ return new Error('Square artifact is closed');
221
+ }
222
+ async function pauseForBusy(signal) {
223
+ if (signal?.aborted)
224
+ throw signal.reason ?? closedError();
257
225
  try {
258
- return await fs.promises.readFile(squarePath);
226
+ await sleep(25, undefined, { signal });
259
227
  }
260
228
  catch (error) {
261
- if (error.code === 'ENOENT') {
262
- throw new InternalSquareError('not_found', `square file not found: ${squarePath}`);
263
- }
229
+ if (signal?.aborted)
230
+ throw signal.reason ?? closedError();
264
231
  throw error;
265
232
  }
266
233
  }
267
- function requireSquareExtension(squarePath) {
268
- if (!squarePath.endsWith('.square')) {
269
- throw new SquareError('invalid_args', `Square artifacts must use the .square extension: ${squarePath}`);
234
+ /**
235
+ * Reads a complete validated snapshot, yielding between transient SQLite lock
236
+ * conflicts. The URI remains create-disabled throughout the retry loop.
237
+ */
238
+ export async function readSquareSnapshot(squarePath, signal) {
239
+ while (true) {
240
+ if (signal?.aborted)
241
+ throw signal.reason ?? closedError();
242
+ try {
243
+ return readSquareSnapshotOnce(squarePath);
244
+ }
245
+ catch (error) {
246
+ if (!isBusy(error))
247
+ throw error;
248
+ await pauseForBusy(signal);
249
+ }
270
250
  }
271
251
  }
272
- async function atomicWrite(target, bytes) {
273
- const temporary = path.join(path.dirname(target), `.${path.basename(target)}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`);
274
- await fs.promises.mkdir(path.dirname(target), { recursive: true });
252
+ /**
253
+ * Executes one synchronous SquareState transition under SQLite's write lock.
254
+ * A busy COMMIT retries on this same transaction, so fn is never replayed.
255
+ */
256
+ export async function transactSquareSnapshot(squarePath, fn, signal, forceCommit = false) {
257
+ requireSquareExtension(squarePath);
258
+ let database;
259
+ let begun = false;
275
260
  try {
276
- await fs.promises.writeFile(temporary, bytes);
277
- await fs.promises.rename(temporary, target);
261
+ database = openExistingDatabase(squarePath);
262
+ // Validate before any persistent pragma can touch an unsupported artifact.
263
+ while (true) {
264
+ try {
265
+ validateDatabase(database);
266
+ break;
267
+ }
268
+ catch (error) {
269
+ if (!isBusy(error))
270
+ throw error;
271
+ await pauseForBusy(signal);
272
+ }
273
+ }
274
+ while (true) {
275
+ if (signal?.aborted)
276
+ throw signal.reason ?? closedError();
277
+ try {
278
+ configureDatabase(database);
279
+ database.exec('BEGIN IMMEDIATE;');
280
+ begun = true;
281
+ break;
282
+ }
283
+ catch (error) {
284
+ if (!isBusy(error))
285
+ throw error;
286
+ await pauseForBusy(signal);
287
+ }
288
+ }
289
+ const current = validateDatabase(database);
290
+ const outcome = fn(structuredClone(current.state), current.revision);
291
+ if (isThenable(outcome)) {
292
+ discardThenable(outcome);
293
+ throw new TypeError('Square artifact transitions must be synchronous.');
294
+ }
295
+ if (typeof outcome !== 'object' || outcome === null || !Object.hasOwn(outcome, 'result')) {
296
+ throw new TypeError('Square artifact transition must return { state?, result }.');
297
+ }
298
+ if (outcome.state === undefined || (!forceCommit && isDeepStrictEqual(outcome.state, current.state))) {
299
+ database.exec('ROLLBACK;');
300
+ begun = false;
301
+ return { result: outcome.result, revision: current.revision, changed: false };
302
+ }
303
+ const state = validateSquareState(outcome.state);
304
+ if (current.revision === Number.MAX_SAFE_INTEGER) {
305
+ throw invalidArtifact('snapshot revision cannot advance beyond the safe integer limit.');
306
+ }
307
+ const revision = current.revision + 1;
308
+ database.prepare(`UPDATE ${SNAPSHOT_TABLE} SET revision = ?, state = ? WHERE id = 1`)
309
+ .run(revision, JSON.stringify(state));
310
+ while (true) {
311
+ if (signal?.aborted)
312
+ throw signal.reason ?? closedError();
313
+ try {
314
+ database.exec('COMMIT;');
315
+ begun = false;
316
+ return { result: outcome.result, revision, changed: true };
317
+ }
318
+ catch (error) {
319
+ if (!isBusy(error))
320
+ throw error;
321
+ await pauseForBusy(signal);
322
+ }
323
+ }
278
324
  }
279
325
  catch (error) {
280
- try {
281
- await fs.promises.unlink(temporary);
326
+ if (begun) {
327
+ try {
328
+ database?.exec('ROLLBACK;');
329
+ }
330
+ catch { /* retain callback and SQLite failures */ }
282
331
  }
283
- catch { }
284
332
  throw error;
285
333
  }
334
+ finally {
335
+ closeQuietly(database);
336
+ }
337
+ }
338
+ /** Replaces an existing validated snapshot in place and always advances its revision. */
339
+ export async function replaceSquareSnapshot(squarePath, state) {
340
+ const replacement = structuredClone(validateSquareState(state));
341
+ const committed = await transactSquareSnapshot(squarePath, () => ({ state: replacement, result: undefined }), undefined, true);
342
+ return { state: replacement, revision: committed.revision };
286
343
  }
287
344
  export async function writeSquareFile(squarePath, squareState) {
288
345
  requireSquareExtension(squarePath);
289
- await atomicWrite(squarePath, encodeSquare(squareState));
346
+ if (await createSquareFile(squarePath, squareState))
347
+ return;
348
+ await replaceSquareSnapshot(squarePath, squareState);
290
349
  }
291
350
  export async function loadSquare(squarePath) {
292
- requireSquareExtension(squarePath);
293
- return decodeSquare(await readArtifact(squarePath));
351
+ return structuredClone((await readSquareSnapshot(squarePath)).state);
294
352
  }
295
353
  export async function probeSquare(squarePath) {
296
354
  if (!squarePath.endsWith('.square'))
297
355
  return undefined;
298
- let descriptor;
299
- try {
300
- descriptor = await fs.promises.open(squarePath, 'r');
301
- const magic = Buffer.alloc(SQUARE_MAGIC.length);
302
- if ((await descriptor.read(magic, 0, magic.length, 0)).bytesRead !== magic.length || !magic.equals(SQUARE_MAGIC)) {
303
- return undefined;
304
- }
305
- }
306
- catch {
307
- return undefined;
308
- }
309
- finally {
310
- if (descriptor !== undefined)
311
- await descriptor.close();
312
- }
313
356
  try {
314
- return await loadSquare(squarePath);
357
+ return structuredClone(readSquareSnapshotOnce(squarePath).state);
315
358
  }
316
359
  catch {
317
360
  return undefined;
@@ -322,9 +365,6 @@ export async function diagnoseSquareFile(squarePath) {
322
365
  return { problems: [], state: await loadSquare(squarePath) };
323
366
  }
324
367
  catch (error) {
325
- return {
326
- unfixable: error instanceof Error ? error.message : String(error),
327
- problems: [],
328
- };
368
+ return { unfixable: error instanceof Error ? error.message : String(error), problems: [] };
329
369
  }
330
370
  }
package/dist/help.js CHANGED
@@ -70,7 +70,7 @@ const COMMANDS = [
70
70
  },
71
71
  {
72
72
  names: ['doctor'], usage: 'doctor', usesSquare: true, group: 'maintenance',
73
- summary: 'Validate binary artifact integrity.',
73
+ summary: 'Validate artifact integrity.',
74
74
  },
75
75
  ];
76
76
  function definitionFor(command) {
package/dist/ports.d.ts CHANGED
@@ -11,10 +11,7 @@ export interface SquareArtifactPort {
11
11
  transact<R>(fn: (state: SquareState, version: number) => {
12
12
  state?: SquareState;
13
13
  result: R;
14
- } | Promise<{
15
- state?: SquareState;
16
- result: R;
17
- }>): Promise<R>;
14
+ }): Promise<R>;
18
15
  changed(sinceVersion: number, timeoutMs: number): Promise<boolean>;
19
16
  close(): Promise<void>;
20
17
  }
@@ -1,17 +1,18 @@
1
- import fs from 'node:fs';
2
1
  import path from 'node:path';
3
- import { createSquareState, probeSquareFile, writeSquareSnapshot, withSquareFileLock, openSquareCell, createMemoryCell, } from './square-storage.js';
2
+ import { createSquareState, createSquareSnapshot, probeSquareFile, writeSquareSnapshot, openSquareCell, createMemoryCell, } from './square-storage.js';
4
3
  import { InternalSquareError, SquareError, } from './model.js';
5
4
  import { closeOpenSquare } from './open-square.js';
6
5
  import { createHostLedgerPort } from './host-ledger-file-adapter.js';
7
6
  /** File-owned artifact creation for the CLI and path-backed public facade. */
8
7
  export async function createSquare(squarePath, options, snippet) {
9
- await withSquareFileLock(squarePath, async () => {
10
- if (await fs.promises.access(squarePath).then(() => true, () => false) && !options.force) {
11
- throw new InternalSquareError('conflict', `Refusing to overwrite existing square: ${squarePath}\nPass -f to overwrite.`);
12
- }
13
- await writeSquareSnapshot(squarePath, await createSquareState(options, snippet));
14
- });
8
+ const state = createSquareState(options, snippet);
9
+ if (options.force) {
10
+ await writeSquareSnapshot(squarePath, state);
11
+ return;
12
+ }
13
+ if (!await createSquareSnapshot(squarePath, state)) {
14
+ throw new InternalSquareError('conflict', `Refusing to overwrite existing square: ${squarePath}\nPass -f to overwrite.`);
15
+ }
15
16
  }
16
17
  function validateBuildOptions(options) {
17
18
  if (options.hardCap !== undefined && options.hardCap !== null
@@ -0,0 +1,6 @@
1
+ import { type BuildOptions, type HardCap, type SquareRuntimeState, type SquareState } from './model.js';
2
+ export declare function validateSquareState(value: unknown): SquareState;
3
+ export declare function emptyRuntimeState(nextActIndex?: number): SquareRuntimeState;
4
+ export declare function createSquareState(options: BuildOptions & {
5
+ hardCap: HardCap;
6
+ }, snippet: string): SquareState;
@@ -0,0 +1,141 @@
1
+ import fs from 'node:fs';
2
+ import { isWakeRouteKind, SquareError, } from './model.js';
3
+ import { parseActivityId } from './square-core.js';
4
+ const guideNames = ['participant', 'architect', 'brainstorm'];
5
+ const guideContents = new Map(await Promise.all(guideNames.map(async (name) => [name, (await fs.promises.readFile(new URL(`../guides/${name}.md`, import.meta.url), 'utf8')).trim()])));
6
+ function invalidArtifact(detail) {
7
+ return new SquareError('invalid_args', `Invalid square artifact: ${detail}`);
8
+ }
9
+ function isObject(value) {
10
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
11
+ }
12
+ function hasExactKeys(value, required, optional = []) {
13
+ const allowed = new Set([...required, ...optional]);
14
+ return required.every((key) => Object.hasOwn(value, key)) && Object.keys(value).every((key) => allowed.has(key));
15
+ }
16
+ function isFiniteNumber(value) {
17
+ return typeof value === 'number' && Number.isFinite(value);
18
+ }
19
+ function isNonNegativeInteger(value) {
20
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
21
+ }
22
+ function isNonblankString(value) {
23
+ return typeof value === 'string' && value.length > 0;
24
+ }
25
+ function isStringArray(value) {
26
+ return Array.isArray(value) && value.every((item) => typeof item === 'string');
27
+ }
28
+ function validateObservation(value) {
29
+ return isObject(value) && hasExactKeys(value, ['state', 'at']) && value.state === 'seen' && isFiniteNumber(value.at);
30
+ }
31
+ function validateWatchLease(value) {
32
+ if (!isObject(value) || !hasExactKeys(value, ['leaseId', 'heartbeatAt', 'expiresAt'], ['filter'])
33
+ || !isNonblankString(value.leaseId) || !isFiniteNumber(value.heartbeatAt)
34
+ || !isFiniteNumber(value.expiresAt) || value.expiresAt < value.heartbeatAt)
35
+ return false;
36
+ if (value.filter === undefined)
37
+ return true;
38
+ return isObject(value.filter) && hasExactKeys(value.filter, [], ['participants', 'mention'])
39
+ && (value.filter.participants === undefined || isStringArray(value.filter.participants))
40
+ && (value.filter.mention === undefined || typeof value.filter.mention === 'string');
41
+ }
42
+ function validateRecord(value, item) {
43
+ return isObject(value) && Object.entries(value).every(([key, candidate]) => key.length > 0 && item(candidate));
44
+ }
45
+ function validateRuntime(value) {
46
+ return isObject(value) && hasExactKeys(value, ['nextActIndex', 'observations', 'leases'])
47
+ && isNonNegativeInteger(value.nextActIndex)
48
+ && validateRecord(value.observations, (candidate) => isObject(candidate)
49
+ && Object.entries(candidate).every(([id, observation]) => parseActivityId(id) !== undefined && validateObservation(observation)))
50
+ && validateRecord(value.leases, validateWatchLease);
51
+ }
52
+ function validateAssignedRuntimeReferences(runtime) {
53
+ for (const observations of Object.values(runtime.observations)) {
54
+ for (const id of Object.keys(observations)) {
55
+ const index = parseActivityId(id);
56
+ if (index === undefined)
57
+ return 'malformed';
58
+ if (index >= runtime.nextActIndex)
59
+ return 'future';
60
+ }
61
+ }
62
+ return 'ok';
63
+ }
64
+ function validateActor(value, required) {
65
+ return required ? isNonblankString(value) : value === undefined || isNonblankString(value);
66
+ }
67
+ function validateStoredAct(value) {
68
+ if (!isObject(value) || typeof value.kind !== 'string' || !isNonNegativeInteger(value.index) || !isFiniteNumber(value.at))
69
+ return false;
70
+ switch (value.kind) {
71
+ case 'join': return hasExactKeys(value, ['kind', 'actor', 'at', 'index']) && validateActor(value.actor, true);
72
+ case 'done': return hasExactKeys(value, ['kind', 'actor', 'at', 'index'], ['body']) && validateActor(value.actor, true) && (value.body === undefined || typeof value.body === 'string');
73
+ case 'say': return hasExactKeys(value, ['kind', 'actor', 'at', 'body', 'index'], ['mentions', 'reach', 'reply'])
74
+ && validateActor(value.actor, true) && typeof value.body === 'string'
75
+ && (value.mentions === undefined || isStringArray(value.mentions)) && (value.reach === undefined || value.reach === 'bell')
76
+ && (value.reply === undefined || isNonNegativeInteger(value.reply));
77
+ case 'hold': return hasExactKeys(value, ['kind', 'at', 'index'], ['actor', 'body']) && validateActor(value.actor, false) && (value.body === undefined || typeof value.body === 'string');
78
+ case 'resume': return hasExactKeys(value, ['kind', 'at', 'index'], ['actor']) && validateActor(value.actor, false);
79
+ case 'read': return hasExactKeys(value, ['kind', 'actor', 'at', 'through', 'index']) && validateActor(value.actor, true) && isNonNegativeInteger(value.through);
80
+ case 'listen':
81
+ case 'ignore': return hasExactKeys(value, ['kind', 'actor', 'target', 'at', 'index']) && validateActor(value.actor, true) && validateActor(value.target, true);
82
+ default: return false;
83
+ }
84
+ }
85
+ function validateActs(value) {
86
+ if (!Array.isArray(value) || !value.every(validateStoredAct))
87
+ return false;
88
+ let previous = -1;
89
+ for (const act of value) {
90
+ if (act.index <= previous || (act.kind === 'say' && act.reply !== undefined && act.reply >= act.index))
91
+ return false;
92
+ previous = act.index;
93
+ }
94
+ return true;
95
+ }
96
+ export function validateSquareState(value) {
97
+ if (!isObject(value) || !hasExactKeys(value, ['hardCap', 'preamble', 'warmup', 'acts', 'runtime'], ['throttlePerMinute', 'routes'])
98
+ || !(value.hardCap === null || (Number.isSafeInteger(value.hardCap) && value.hardCap > 0))
99
+ || (value.throttlePerMinute !== undefined && (!Number.isSafeInteger(value.throttlePerMinute) || value.throttlePerMinute <= 0))
100
+ || !isStringArray(value.preamble) || !isStringArray(value.warmup) || !validateActs(value.acts)
101
+ || (value.routes !== undefined && (!Array.isArray(value.routes) || !value.routes.every((route) => isObject(route) && isWakeRouteKind(route.kind)
102
+ && typeof route.location === 'string' && typeof route.participant === 'string' && typeof route.sessionId === 'string'
103
+ && typeof route.channel === 'string' && isObject(route.address) && Object.values(route.address).every((item) => typeof item === 'string')
104
+ && typeof route.updatedAt === 'number')))
105
+ || !validateRuntime(value.runtime))
106
+ throw invalidArtifact('snapshot schema is malformed.');
107
+ const historyBoundary = value.acts.at(-1)?.index ?? -1;
108
+ if (value.runtime.nextActIndex <= historyBoundary)
109
+ throw invalidArtifact('nextActIndex is behind the activity history.');
110
+ const references = validateAssignedRuntimeReferences(value.runtime);
111
+ if (references === 'malformed')
112
+ throw invalidArtifact('snapshot schema is malformed.');
113
+ if (references === 'future')
114
+ throw invalidArtifact('runtime references an unassigned activity index.');
115
+ return value;
116
+ }
117
+ export function emptyRuntimeState(nextActIndex = 0) {
118
+ return { nextActIndex, observations: {}, leases: {} };
119
+ }
120
+ function normalizedLines(value) {
121
+ const normalized = value.replace(/\r\n/g, '\n').trim();
122
+ return normalized === '' ? [] : normalized.split('\n');
123
+ }
124
+ function readGuide(name) {
125
+ const guide = guideContents.get(name);
126
+ if (guide === undefined)
127
+ throw new SquareError('invalid_args', `Unknown square guide: ${name}`);
128
+ return guide;
129
+ }
130
+ export function createSquareState(options, snippet) {
131
+ const guides = [readGuide('participant')];
132
+ if (options.template !== undefined)
133
+ guides.push(readGuide(options.template));
134
+ return {
135
+ hardCap: options.hardCap,
136
+ ...(options.throttlePerMinute === undefined ? {} : { throttlePerMinute: options.throttlePerMinute }),
137
+ preamble: normalizedLines(snippet),
138
+ warmup: normalizedLines(guides.join('\n\n')),
139
+ acts: [], routes: [], runtime: emptyRuntimeState(),
140
+ };
141
+ }
@@ -1,20 +1,17 @@
1
1
  import { createSquareState, diagnoseSquareFile as diagnoseArtifactFile } from './artifact.js';
2
+ import type { SquareState } from './model.js';
2
3
  import type { StateCell } from './state-cell.js';
3
- import { type SquareState } from './model.js';
4
- /**
5
- * The only production module allowed to cross the .square byte boundary.
6
- * Consumers receive a SquareState and never need to know which codec or lock
7
- * protects it.
8
- */
9
- export { createSquareState, };
4
+ export { createSquareState };
10
5
  export declare function readSquareFile(squarePath: string): Promise<SquareState>;
11
6
  export declare function probeSquareFile(squarePath: string): Promise<SquareState | undefined>;
12
7
  export declare function diagnoseSquareFile(squarePath: string): ReturnType<typeof diagnoseArtifactFile>;
8
+ /** Test-helper snapshot replacement backed by one SQLite artifact. */
13
9
  export declare function writeSquareSnapshot(squarePath: string, squareState: SquareState): Promise<void>;
14
- export declare function withSquareFileLock<T>(squarePath: string, fn: () => T | Promise<T>): Promise<T>;
10
+ /** Race-safe non-force creation for the file adapter. */
11
+ export declare function createSquareSnapshot(squarePath: string, squareState: SquareState): Promise<boolean>;
15
12
  /** In-process cell for fast behavior tests and embedded consumers. */
16
13
  export declare function createMemoryCell(initial: SquareState): StateCell;
17
- /** File-backed cell retaining the existing .square codec and lock boundary. */
14
+ /** SQLite-backed cell. Revisions are read from the authoritative database, not file metadata. */
18
15
  export declare function createFileCell(squarePath: string): StateCell;
19
- /** Consumer-facing file cell factory; keeps storage choice behind this module. */
16
+ /** Consumer-facing file cell factory; keeps SQLite framing behind this module. */
20
17
  export declare function openSquareCell(squarePath: string): StateCell;
@@ -1,15 +1,9 @@
1
- import { setTimeout as sleep } from 'node:timers/promises';
2
1
  import fs from 'node:fs';
3
2
  import path from 'node:path';
4
- import { createSquareState, diagnoseSquareFile as diagnoseArtifactFile, loadSquare, probeSquare, writeSquareFile, } from './artifact.js';
5
- import { withFileLock } from './file-lock.js';
6
- import { LOCK_RETRY_MS } from './runtime.js';
7
- /**
8
- * The only production module allowed to cross the .square byte boundary.
9
- * Consumers receive a SquareState and never need to know which codec or lock
10
- * protects it.
11
- */
12
- export { createSquareState, };
3
+ import { setTimeout as sleep } from 'node:timers/promises';
4
+ import { isDeepStrictEqual } from 'node:util';
5
+ import { createSquareFile, createSquareState, diagnoseSquareFile as diagnoseArtifactFile, loadSquare, probeSquare, readSquareSnapshot, transactSquareSnapshot, writeSquareFile, } from './artifact.js';
6
+ export { createSquareState };
13
7
  async function canonicalPath(squarePath) {
14
8
  const absolute = path.resolve(squarePath);
15
9
  try {
@@ -22,49 +16,24 @@ async function canonicalPath(squarePath) {
22
16
  return path.join(parent, path.basename(absolute));
23
17
  }
24
18
  }
25
- async function squareFileExists(squarePath) {
26
- try {
27
- await fs.promises.access(squarePath);
28
- return true;
29
- }
30
- catch (error) {
31
- if (error.code === 'ENOENT')
32
- return false;
33
- throw error;
34
- }
35
- }
36
- async function withSquareFileReadLock(squarePath, fn) {
37
- if (!await squareFileExists(squarePath))
38
- return fn();
39
- try {
40
- return await withFileLock(`${squarePath}.lock`, { retryMs: LOCK_RETRY_MS, createParent: false }, fn);
41
- }
42
- catch (error) {
43
- if (!await squareFileExists(squarePath))
44
- return fn();
45
- throw error;
46
- }
47
- }
48
19
  export async function readSquareFile(squarePath) {
49
- const canonical = await canonicalPath(squarePath);
50
- return withSquareFileReadLock(canonical, () => loadSquare(canonical));
20
+ return loadSquare(await canonicalPath(squarePath));
51
21
  }
52
22
  export async function probeSquareFile(squarePath) {
53
23
  if (!squarePath.endsWith('.square'))
54
24
  return undefined;
55
- const canonical = await canonicalPath(squarePath);
56
- return withSquareFileReadLock(canonical, () => probeSquare(canonical));
25
+ return probeSquare(await canonicalPath(squarePath));
57
26
  }
58
27
  export async function diagnoseSquareFile(squarePath) {
59
- const canonical = await canonicalPath(squarePath);
60
- return withSquareFileReadLock(canonical, () => diagnoseArtifactFile(canonical));
28
+ return diagnoseArtifactFile(await canonicalPath(squarePath));
61
29
  }
30
+ /** Test-helper snapshot replacement backed by one SQLite artifact. */
62
31
  export async function writeSquareSnapshot(squarePath, squareState) {
63
32
  await writeSquareFile(await canonicalPath(squarePath), squareState);
64
33
  }
65
- export async function withSquareFileLock(squarePath, fn) {
66
- const canonical = await canonicalPath(squarePath);
67
- return withFileLock(`${canonical}.lock`, { retryMs: LOCK_RETRY_MS }, fn);
34
+ /** Race-safe non-force creation for the file adapter. */
35
+ export async function createSquareSnapshot(squarePath, squareState) {
36
+ return createSquareFile(await canonicalPath(squarePath), squareState);
68
37
  }
69
38
  function cloneState(squareState) {
70
39
  return structuredClone(squareState);
@@ -73,6 +42,16 @@ function assertCellOpen(closed) {
73
42
  if (closed)
74
43
  throw new Error('StateCell is closed');
75
44
  }
45
+ function isThenable(value) {
46
+ return (typeof value === 'object' || typeof value === 'function') && value !== null
47
+ && typeof value.then === 'function';
48
+ }
49
+ function discardThenable(value) {
50
+ void Promise.resolve(value).catch(() => undefined);
51
+ }
52
+ function isBusy(error) {
53
+ return error instanceof Error && /database is locked|database is busy|SQLITE_BUSY/i.test(error.message);
54
+ }
76
55
  /** In-process cell for fast behavior tests and embedded consumers. */
77
56
  export function createMemoryCell(initial) {
78
57
  let state = cloneState(initial);
@@ -89,15 +68,21 @@ export function createMemoryCell(initial) {
89
68
  waiter.resolve(true);
90
69
  }
91
70
  }
92
- const cell = {
71
+ return {
93
72
  transact(fn) {
94
73
  assertCellOpen(closed);
95
- let operation;
96
- operation = tail.then(() => {
74
+ const operation = tail.then(() => {
97
75
  assertCellOpen(closed);
98
- const working = cloneState(state);
99
- const outcome = fn(working, version);
100
- if (outcome.state !== undefined) {
76
+ const current = cloneState(state);
77
+ const outcome = fn(current, version);
78
+ if (isThenable(outcome)) {
79
+ discardThenable(outcome);
80
+ throw new TypeError('Square artifact transitions must be synchronous.');
81
+ }
82
+ if (typeof outcome !== 'object' || outcome === null || !Object.hasOwn(outcome, 'result')) {
83
+ throw new TypeError('Square artifact transition must return { state?, result }.');
84
+ }
85
+ if (outcome.state !== undefined && !isDeepStrictEqual(outcome.state, state)) {
101
86
  state = cloneState(outcome.state);
102
87
  version += 1;
103
88
  publish();
@@ -110,6 +95,7 @@ export function createMemoryCell(initial) {
110
95
  async read() {
111
96
  assertCellOpen(closed);
112
97
  await tail;
98
+ assertCellOpen(closed);
113
99
  return { state: cloneState(state), version };
114
100
  },
115
101
  changed(sinceVersion, timeoutMs) {
@@ -122,10 +108,7 @@ export function createMemoryCell(initial) {
122
108
  const waiter = {
123
109
  since: sinceVersion,
124
110
  resolve,
125
- timer: setTimeout(() => {
126
- waiters.delete(waiter);
127
- resolve(false);
128
- }, timeoutMs),
111
+ timer: setTimeout(() => { waiters.delete(waiter); resolve(false); }, timeoutMs),
129
112
  };
130
113
  waiters.add(waiter);
131
114
  });
@@ -142,93 +125,79 @@ export function createMemoryCell(initial) {
142
125
  }
143
126
  },
144
127
  };
145
- return cell;
146
- }
147
- async function fileFingerprint(squarePath) {
148
- try {
149
- const stat = await fs.promises.stat(squarePath);
150
- return `${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`;
151
- }
152
- catch {
153
- return 'missing';
154
- }
155
128
  }
156
- /** File-backed cell retaining the existing .square codec and lock boundary. */
129
+ /** SQLite-backed cell. Revisions are read from the authoritative database, not file metadata. */
157
130
  export function createFileCell(squarePath) {
158
131
  let closed = false;
159
- let version = 0;
160
- let fingerprint;
161
- let cached;
162
- let canonicalSquarePath;
163
- async function storagePath() {
164
- canonicalSquarePath ??= await canonicalPath(squarePath);
165
- return canonicalSquarePath;
166
- }
167
- async function observe() {
168
- const next = await fileFingerprint(await storagePath());
169
- if (fingerprint === undefined) {
170
- fingerprint = next;
171
- }
172
- else if (next !== fingerprint) {
173
- fingerprint = next;
174
- cached = undefined;
175
- version += 1;
176
- }
177
- return fingerprint;
132
+ let storage;
133
+ let tail = Promise.resolve();
134
+ const cancel = new AbortController();
135
+ function storagePath() {
136
+ storage ??= canonicalPath(squarePath);
137
+ return storage;
178
138
  }
179
- async function currentStateUnderLock() {
180
- const observed = await observe();
181
- if (cached?.fingerprint === observed)
182
- return cloneState(cached.state);
183
- const decoded = await loadSquare(await storagePath());
184
- cached = { fingerprint: observed, state: cloneState(decoded) };
185
- return cloneState(cached.state);
139
+ function abortError() {
140
+ return new Error('StateCell is closed');
186
141
  }
187
142
  return {
188
- async transact(fn) {
143
+ transact(fn) {
189
144
  assertCellOpen(closed);
190
- const canonical = await storagePath();
191
- return withFileLock(`${canonical}.lock`, { retryMs: LOCK_RETRY_MS }, async () => {
145
+ const operation = tail.then(async () => {
192
146
  assertCellOpen(closed);
193
- const current = await currentStateUnderLock();
194
- const working = cloneState(current);
195
- const outcome = fn(working, version);
196
- if (outcome.state !== undefined) {
197
- await writeSquareFile(canonical, outcome.state);
198
- fingerprint = await fileFingerprint(canonical);
199
- cached = { fingerprint, state: cloneState(outcome.state) };
200
- version += 1;
201
- }
202
- return outcome.result;
147
+ const result = await transactSquareSnapshot(await storagePath(), fn, cancel.signal);
148
+ return result.result;
203
149
  });
150
+ tail = operation.then(() => undefined, () => undefined);
151
+ return operation;
204
152
  },
205
153
  async read() {
206
154
  assertCellOpen(closed);
207
- return withSquareFileReadLock(await storagePath(), async () => {
208
- assertCellOpen(closed);
209
- return { state: await currentStateUnderLock(), version };
210
- });
155
+ await tail;
156
+ assertCellOpen(closed);
157
+ const snapshot = await readSquareSnapshot(await storagePath(), cancel.signal);
158
+ return { state: cloneState(snapshot.state), version: snapshot.revision };
211
159
  },
212
160
  async changed(sinceVersion, timeoutMs) {
213
161
  assertCellOpen(closed);
214
162
  const deadline = Date.now() + Math.max(0, timeoutMs);
215
- while (true) {
216
- await observe();
217
- if (version > sinceVersion)
218
- return true;
163
+ while (!closed) {
219
164
  const remaining = deadline - Date.now();
220
- if (remaining <= 0)
165
+ const timeout = AbortSignal.timeout(Math.max(1, remaining));
166
+ const readSignal = AbortSignal.any([cancel.signal, timeout]);
167
+ try {
168
+ if ((await readSquareSnapshot(await storagePath(), readSignal)).revision > sinceVersion)
169
+ return true;
170
+ }
171
+ catch (error) {
172
+ if (closed || cancel.signal.aborted || timeout.aborted)
173
+ return false;
174
+ if (!isBusy(error))
175
+ throw error;
176
+ }
177
+ const nextRemaining = deadline - Date.now();
178
+ if (nextRemaining <= 0)
221
179
  return false;
222
- await sleep(Math.min(25, remaining));
223
- assertCellOpen(closed);
180
+ try {
181
+ await sleep(Math.min(25, nextRemaining), undefined, { signal: cancel.signal });
182
+ }
183
+ catch (error) {
184
+ if (closed || cancel.signal.aborted)
185
+ return false;
186
+ throw error;
187
+ }
224
188
  }
189
+ return false;
225
190
  },
226
191
  async close() {
192
+ if (closed)
193
+ return;
227
194
  closed = true;
195
+ cancel.abort(abortError());
196
+ await tail;
228
197
  },
229
198
  };
230
199
  }
231
- /** Consumer-facing file cell factory; keeps storage choice behind this module. */
200
+ /** Consumer-facing file cell factory; keeps SQLite framing behind this module. */
232
201
  export function openSquareCell(squarePath) {
233
202
  return createFileCell(squarePath);
234
203
  }
@@ -4,10 +4,7 @@ export interface StateCell {
4
4
  transact<R>(fn: (state: SquareState, version: number) => {
5
5
  state?: SquareState;
6
6
  result: R;
7
- } | Promise<{
8
- state?: SquareState;
9
- result: R;
10
- }>): Promise<R>;
7
+ }): Promise<R>;
11
8
  read(): Promise<{
12
9
  state: SquareState;
13
10
  version: number;
@@ -110,7 +110,7 @@ row.
110
110
  1. Resolve the requested name and collect the currently active bindings for
111
111
  that name. The current session is excluded from the old-owner set only when
112
112
  it is already the owner and this is a reconnect, not a kick.
113
- 2. Under the artifact lock, commit two lifecycle activities as one transaction:
113
+ 2. In one artifact transaction, commit two lifecycle activities:
114
114
  `done` for the standing participant, followed immediately by `join` for the
115
115
  reclaimed name. If this transaction cannot commit, nothing is kicked.
116
116
  3. In the same artifact transaction, remove every route for the kicked
@@ -152,8 +152,8 @@ owner. It must not append a second `done` for the new owner.
152
152
  ## Concurrency and Recovery
153
153
 
154
154
  The artifact transaction serializes participant lifecycle activities. The host
155
- ledger lock serializes binding rows. These locks protect different facts and
156
- must not be replaced with a copied sidecar or a display-level mutex.
155
+ ledger lock serializes binding rows. They protect different facts and must not
156
+ be replaced with a copied sidecar or a display-level mutex.
157
157
 
158
158
  The safe ordering is:
159
159
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/square",
3
- "version": "0.3.45",
3
+ "version": "0.3.46",
4
4
  "description": "A shared public square where agents join, catch activity, express, and step out when done.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,7 +37,7 @@
37
37
  "author": "astrosheep",
38
38
  "license": "MIT",
39
39
  "engines": {
40
- "node": ">=22.5.0"
40
+ "node": "^22.16.0 || >=24.0.0"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@getpaseo/client": "^0.3.1",