@astrosheep/square 0.3.70 → 0.3.72
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/dist/activity-feed.d.ts +4 -5
- package/dist/activity-feed.js +14 -10
- package/dist/artifact.d.ts +26 -33
- package/dist/artifact.js +346 -317
- package/dist/attention-presentation.js +2 -9
- package/dist/automatic-session.js +6 -6
- package/dist/boundary-presentation.js +3 -3
- package/dist/catch-decisions.d.ts +19 -12
- package/dist/catch-decisions.js +32 -68
- package/dist/claude-delivery.js +7 -7
- package/dist/claude-mod.js +18 -7
- package/dist/cli/catch-presentation.d.ts +7 -0
- package/dist/cli/catch-presentation.js +55 -0
- package/dist/cli/context.d.ts +1 -1
- package/dist/cli/context.js +5 -23
- package/dist/cli/express-presentation.d.ts +10 -0
- package/dist/cli/express-presentation.js +196 -0
- package/dist/cli/maintenance-commands.d.ts +4 -0
- package/dist/cli/maintenance-commands.js +29 -6
- package/dist/cli/observation-commands.d.ts +7 -5
- package/dist/cli/observation-commands.js +275 -262
- package/dist/cli/program.d.ts +1 -1
- package/dist/cli/program.js +43 -56
- package/dist/cli/registry.js +2 -1
- package/dist/cli/square-commands.d.ts +3 -9
- package/dist/cli/square-commands.js +134 -255
- package/dist/codex-queue.js +4 -1
- package/dist/decisions.d.ts +26 -74
- package/dist/decisions.js +190 -335
- package/dist/delivery-health.js +13 -22
- package/dist/delivery-operations.d.ts +0 -11
- package/dist/delivery-operations.js +44 -33
- package/dist/delivery.d.ts +33 -32
- package/dist/delivery.js +70 -81
- package/dist/help.js +6 -1
- package/dist/inbox.d.ts +27 -0
- package/dist/inbox.js +55 -18
- package/dist/index.d.ts +2 -4
- package/dist/index.js +1 -1
- package/dist/list.d.ts +7 -1
- package/dist/list.js +49 -122
- package/dist/mcp.d.ts +1 -1
- package/dist/mcp.js +7 -37
- package/dist/model.d.ts +4 -44
- package/dist/model.js +1 -3
- package/dist/notifications.d.ts +1 -1
- package/dist/notifications.js +13 -7
- package/dist/open-square.d.ts +16 -2
- package/dist/open-square.js +40 -1
- package/dist/packages/agent-delivery/src/claude.d.ts +2 -11
- package/dist/packages/agent-delivery/src/index.d.ts +2 -42
- package/dist/packages/agent-delivery/src/opencode.d.ts +2 -11
- package/dist/packages/agent-delivery/src/paseo.d.ts +2 -13
- package/dist/packages/agent-delivery/src/pi-client.d.ts +2 -9
- package/dist/packages/agent-delivery/src/types.d.ts +42 -0
- package/dist/participant-host.d.ts +6 -3
- package/dist/participant-host.js +44 -38
- package/dist/perception-projection.d.ts +6 -15
- package/dist/perception-projection.js +6 -26
- package/dist/plugins/opencode/delivery.js +9 -9
- package/dist/plugins/opencode/index.js +3 -3
- package/dist/ports.d.ts +156 -11
- package/dist/presentation-operations.d.ts +1 -1
- package/dist/presentation-operations.js +35 -44
- package/dist/presentation.d.ts +12 -19
- package/dist/presentation.js +33 -54
- package/dist/registry.d.ts +2 -0
- package/dist/registry.js +1 -1
- package/dist/routes.d.ts +7 -14
- package/dist/routes.js +34 -72
- package/dist/runtime.d.ts +12 -39
- package/dist/runtime.js +33 -164
- package/dist/square-actions.d.ts +11 -21
- package/dist/square-actions.js +171 -275
- package/dist/square-application.d.ts +31 -55
- package/dist/square-application.js +625 -141
- package/dist/square-core.d.ts +3 -94
- package/dist/square-core.js +8 -353
- package/dist/square-domain.d.ts +24 -0
- package/dist/square-domain.js +77 -0
- package/dist/square-facade.d.ts +360 -9
- package/dist/square-migration.d.ts +24 -0
- package/dist/square-migration.js +211 -0
- package/dist/square-projections.d.ts +2 -42
- package/dist/square-projections.js +1 -37
- package/dist/square-sqlite.d.ts +59 -0
- package/dist/square-sqlite.js +702 -0
- package/dist/stream.d.ts +3 -10
- package/dist/stream.js +81 -52
- package/dist/views.d.ts +27 -95
- package/dist/views.js +217 -87
- package/dist/wake-eligibility.d.ts +15 -5
- package/dist/wake-eligibility.js +32 -16
- package/dist/wakes.d.ts +3 -3
- package/dist/wakes.js +31 -27
- package/guides/application-facade-delivery-evidence.md +200 -0
- package/guides/application-facade-design.md +95 -0
- package/guides/change-waits.md +4 -4
- package/guides/migration.md +77 -0
- package/guides/postcommit-simplification-performance.md +36 -0
- package/guides/primitive-catch-consolidation.md +85 -0
- package/guides/primitive-history-consolidation.md +76 -0
- package/guides/primitive-status-consolidation.md +87 -0
- package/guides/primitive-storage-consolidation.md +128 -0
- package/guides/row-runtime-delivery-evidence.md +160 -0
- package/guides/row-storage-design.md +567 -0
- package/package.json +1 -1
- package/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/plugins/codex/.codex-plugin/plugin.json +1 -1
- package/plugins/opencode/delivery.ts +11 -11
- package/plugins/opencode/index.ts +3 -3
- package/plugins/paseo/client/square-message.tsx +77 -64
- package/plugins/paseo/shared/message.ts +16 -0
- package/plugins/pi/square-pi.js +5 -1
- package/dist/activity.d.ts +0 -14
- package/dist/activity.js +0 -248
- package/dist/square-file-adapter.d.ts +0 -26
- package/dist/square-file-adapter.js +0 -85
- package/dist/square-state.d.ts +0 -6
- package/dist/square-state.js +0 -138
- package/dist/square-storage.d.ts +0 -14
- package/dist/square-storage.js +0 -188
- package/dist/square-wiring.d.ts +0 -33
- package/dist/square-wiring.js +0 -109
- package/dist/watch.d.ts +0 -8
- package/dist/watch.js +0 -252
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
import { DatabaseSync as MemoryDatabase } from 'node:sqlite';
|
|
2
|
+
import { SquareError, nameKey, isWakeRouteKind } from './model.js';
|
|
3
|
+
import { deriveLanding, landingFacts } from './square-domain.js';
|
|
4
|
+
export function emptyRuntimeState(nextActIndex = 0) {
|
|
5
|
+
return { nextActIndex, observations: {}, leases: {} };
|
|
6
|
+
}
|
|
7
|
+
export const APPLICATION_ID = 0x53515245;
|
|
8
|
+
const V1_USER_VERSION = 1;
|
|
9
|
+
export const V1_SNAPSHOT_TABLE = 'square_snapshot';
|
|
10
|
+
const V1_SNAPSHOT_DDL = `CREATE TABLE ${V1_SNAPSHOT_TABLE} (id INTEGER PRIMARY KEY CHECK(id=1), revision INTEGER NOT NULL CHECK(revision>=0), state TEXT NOT NULL)`;
|
|
11
|
+
const V1_SNAPSHOT_COLUMNS = [
|
|
12
|
+
{ cid: 0, name: 'id', type: 'INTEGER', notnull: 0, dflt_value: null, pk: 1, hidden: 0 },
|
|
13
|
+
{ cid: 1, name: 'revision', type: 'INTEGER', notnull: 1, dflt_value: null, pk: 0, hidden: 0 },
|
|
14
|
+
{ cid: 2, name: 'state', type: 'TEXT', notnull: 1, dflt_value: null, pk: 0, hidden: 0 },
|
|
15
|
+
];
|
|
16
|
+
/**
|
|
17
|
+
* The one recovery command for an owned legacy artifact, with a shell-quoted
|
|
18
|
+
* path. Kept beside framing recognition so the refusal and migration
|
|
19
|
+
* instruction cannot drift apart. This codec stays free of the renderer.
|
|
20
|
+
*/
|
|
21
|
+
export function migrationRecoveryCommand(location) {
|
|
22
|
+
return `square --location '${location.replace(/'/g, `'\\''`)}' migrate`;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Read-only legacy framing: application id, format version, journal mode and the
|
|
26
|
+
* exact owned snapshot schema. It decodes no snapshot payload, writes no pragma
|
|
27
|
+
* and runs no DDL, so ordinary open can establish ownership before offering
|
|
28
|
+
* the migration command. Anything else is unrelated or unsupported, not repaired.
|
|
29
|
+
*/
|
|
30
|
+
export function validateLegacyFraming(database, applicationId, location = '') {
|
|
31
|
+
const identity = database.prepare('SELECT application_id, user_version FROM pragma_application_id, pragma_user_version').get();
|
|
32
|
+
if (identity.application_id !== applicationId)
|
|
33
|
+
throw invalidArtifact('unrelated SQLite database.');
|
|
34
|
+
if (identity.user_version !== V1_USER_VERSION)
|
|
35
|
+
throw invalidArtifact('unsupported SQLite format version.');
|
|
36
|
+
const journalMode = database.prepare('PRAGMA journal_mode').get().journal_mode;
|
|
37
|
+
if (typeof journalMode !== 'string' || journalMode !== (location === ':memory:' ? 'memory' : 'delete'))
|
|
38
|
+
throw invalidArtifact('unsupported journal mode.');
|
|
39
|
+
const objects = database.prepare("SELECT type, name, tbl_name, sql FROM sqlite_schema WHERE name NOT GLOB 'sqlite_*' ORDER BY type, name")
|
|
40
|
+
.all();
|
|
41
|
+
if (objects.length !== 1)
|
|
42
|
+
throw invalidArtifact('unsupported snapshot schema.');
|
|
43
|
+
const [object] = objects;
|
|
44
|
+
if (object.type !== 'table' || object.name !== V1_SNAPSHOT_TABLE || object.tbl_name !== V1_SNAPSHOT_TABLE
|
|
45
|
+
|| typeof object.sql !== 'string' || normalizedLegacySql(object.sql) !== normalizedLegacySql(V1_SNAPSHOT_DDL))
|
|
46
|
+
throw invalidArtifact('unsupported snapshot schema.');
|
|
47
|
+
const columns = database.prepare('SELECT cid, name, type, "notnull", dflt_value, pk, hidden FROM pragma_table_xinfo(?) ORDER BY cid')
|
|
48
|
+
.all(V1_SNAPSHOT_TABLE);
|
|
49
|
+
if (columns.length !== V1_SNAPSHOT_COLUMNS.length || !columns.every((column, index) => {
|
|
50
|
+
const expected = V1_SNAPSHOT_COLUMNS[index];
|
|
51
|
+
return column.cid === expected.cid && column.name === expected.name && column.type === expected.type
|
|
52
|
+
&& column.notnull === expected.notnull && column.dflt_value === expected.dflt_value
|
|
53
|
+
&& column.pk === expected.pk && column.hidden === expected.hidden;
|
|
54
|
+
}))
|
|
55
|
+
throw invalidArtifact('unsupported snapshot schema.');
|
|
56
|
+
return { applicationId, userVersion: V1_USER_VERSION, journalMode };
|
|
57
|
+
}
|
|
58
|
+
function normalizedLegacySql(sql) {
|
|
59
|
+
return sql.trim().replace(/\s+/g, ' ').replace(/\s*(>=|[(),=])\s*/g, '$1');
|
|
60
|
+
}
|
|
61
|
+
export const SCHEMA_SQL = `CREATE TABLE square_meta (
|
|
62
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
63
|
+
revision INTEGER NOT NULL CHECK (revision BETWEEN 0 AND 9007199254740991),
|
|
64
|
+
next_act_index INTEGER NOT NULL CHECK (next_act_index BETWEEN 0 AND 9007199254740991),
|
|
65
|
+
hard_cap INTEGER CHECK (hard_cap BETWEEN 1 AND 9007199254740991),
|
|
66
|
+
throttle_per_minute INTEGER CHECK (throttle_per_minute BETWEEN 1 AND 9007199254740991),
|
|
67
|
+
preamble_json TEXT NOT NULL CHECK (json_valid(preamble_json)),
|
|
68
|
+
warmup_json TEXT NOT NULL CHECK (json_valid(warmup_json))
|
|
69
|
+
);
|
|
70
|
+
CREATE TABLE activities (
|
|
71
|
+
act_index INTEGER PRIMARY KEY CHECK (act_index BETWEEN 0 AND 9007199254740991),
|
|
72
|
+
at REAL NOT NULL,
|
|
73
|
+
kind TEXT NOT NULL CHECK (kind IN ('join','done','say','hold','resume','read','listen','ignore')),
|
|
74
|
+
actor_json TEXT COLLATE BINARY,
|
|
75
|
+
actor_key_json TEXT COLLATE BINARY,
|
|
76
|
+
reach TEXT CHECK (reach = 'bell'),
|
|
77
|
+
reply_index INTEGER CHECK (reply_index >= 0 AND reply_index < act_index),
|
|
78
|
+
payload_json TEXT NOT NULL CHECK (json_valid(payload_json)),
|
|
79
|
+
CHECK ((actor_json IS NULL) = (actor_key_json IS NULL)),
|
|
80
|
+
CHECK (kind IN ('hold','resume') OR actor_json IS NOT NULL),
|
|
81
|
+
CHECK (kind = 'say' OR (reach IS NULL AND reply_index IS NULL))
|
|
82
|
+
);
|
|
83
|
+
CREATE INDEX activity_kind_index ON activities(kind, act_index);
|
|
84
|
+
CREATE INDEX activity_actor_index ON activities(actor_key_json, act_index);
|
|
85
|
+
CREATE INDEX activity_say_actor_index ON activities(actor_key_json, act_index) WHERE kind='say';
|
|
86
|
+
CREATE INDEX activity_say_time ON activities(at, act_index) WHERE kind='say';
|
|
87
|
+
CREATE INDEX activity_bell_actor_time ON activities(actor_key_json, at, act_index) WHERE kind='say' AND reach='bell';
|
|
88
|
+
|
|
89
|
+
CREATE TABLE receipts (
|
|
90
|
+
owner_json TEXT COLLATE BINARY NOT NULL,
|
|
91
|
+
act_index INTEGER NOT NULL CHECK (act_index BETWEEN 0 AND 9007199254740991),
|
|
92
|
+
at REAL NOT NULL,
|
|
93
|
+
PRIMARY KEY (owner_json, act_index)
|
|
94
|
+
) WITHOUT ROWID;
|
|
95
|
+
CREATE INDEX receipt_owner_time ON receipts(owner_json, at);
|
|
96
|
+
CREATE TABLE catch_leases (
|
|
97
|
+
owner_json TEXT COLLATE BINARY PRIMARY KEY NOT NULL,
|
|
98
|
+
lease_id_json TEXT NOT NULL,
|
|
99
|
+
heartbeat_at REAL NOT NULL,
|
|
100
|
+
expires_at REAL NOT NULL CHECK (expires_at >= heartbeat_at),
|
|
101
|
+
filter_json TEXT CHECK (filter_json IS NULL OR json_valid(filter_json))
|
|
102
|
+
) WITHOUT ROWID;
|
|
103
|
+
|
|
104
|
+
CREATE TABLE routes (
|
|
105
|
+
position INTEGER PRIMARY KEY CHECK (position >= 0),
|
|
106
|
+
participant_key_json TEXT COLLATE BINARY NOT NULL,
|
|
107
|
+
session_json TEXT COLLATE BINARY NOT NULL,
|
|
108
|
+
kind TEXT NOT NULL CHECK (kind IN ('opencode-server','codex-queue','claude-native','paseo')),
|
|
109
|
+
updated_at REAL NOT NULL,
|
|
110
|
+
payload_json TEXT NOT NULL CHECK (json_valid(payload_json))
|
|
111
|
+
);
|
|
112
|
+
CREATE INDEX route_participant ON routes(participant_key_json, position);
|
|
113
|
+
CREATE INDEX route_session ON routes(session_json, position);
|
|
114
|
+
|
|
115
|
+
CREATE TABLE participants (
|
|
116
|
+
name_key_json TEXT COLLATE BINARY PRIMARY KEY NOT NULL,
|
|
117
|
+
name_json TEXT NOT NULL,
|
|
118
|
+
first_index INTEGER NOT NULL UNIQUE,
|
|
119
|
+
joined INTEGER NOT NULL CHECK (joined IN (0,1)),
|
|
120
|
+
done INTEGER NOT NULL CHECK (done IN (0,1)),
|
|
121
|
+
say_count INTEGER NOT NULL CHECK (say_count >= 0),
|
|
122
|
+
last_active_at REAL,
|
|
123
|
+
last_join_index INTEGER,
|
|
124
|
+
CHECK (NOT (joined=1 AND done=1))
|
|
125
|
+
) WITHOUT ROWID;
|
|
126
|
+
CREATE TABLE attention (
|
|
127
|
+
listener_key_json TEXT COLLATE BINARY NOT NULL,
|
|
128
|
+
target_key_json TEXT COLLATE BINARY NOT NULL,
|
|
129
|
+
target_json TEXT NOT NULL,
|
|
130
|
+
kind TEXT NOT NULL CHECK (kind IN ('listen','ignore')),
|
|
131
|
+
since_index INTEGER NOT NULL,
|
|
132
|
+
PRIMARY KEY (listener_key_json, target_key_json)
|
|
133
|
+
) WITHOUT ROWID;
|
|
134
|
+
CREATE INDEX attention_target ON attention(target_key_json, kind, listener_key_json);
|
|
135
|
+
CREATE TABLE landed_audience (
|
|
136
|
+
act_index INTEGER NOT NULL,
|
|
137
|
+
recipient_key_json TEXT COLLATE BINARY NOT NULL,
|
|
138
|
+
recipient_order INTEGER NOT NULL CHECK (recipient_order >= 0),
|
|
139
|
+
route TEXT NOT NULL CHECK (route IN ('mention','reply','attention','bell')),
|
|
140
|
+
PRIMARY KEY (act_index, recipient_key_json),
|
|
141
|
+
UNIQUE (act_index, recipient_order)
|
|
142
|
+
) WITHOUT ROWID;
|
|
143
|
+
CREATE INDEX audience_recipient ON landed_audience(recipient_key_json, act_index);`;
|
|
144
|
+
const encode = JSON.stringify;
|
|
145
|
+
const key = (name) => encode(nameKey(name));
|
|
146
|
+
export function invalidArtifact(detail) {
|
|
147
|
+
return new SquareError('invalid_args', `Invalid square artifact: ${detail}`);
|
|
148
|
+
}
|
|
149
|
+
function requireFact(ok, detail = 'row is malformed.') {
|
|
150
|
+
if (!ok)
|
|
151
|
+
throw invalidArtifact(detail);
|
|
152
|
+
}
|
|
153
|
+
function integer(value, minimum = 0) {
|
|
154
|
+
requireFact(Number.isSafeInteger(value) && value >= minimum);
|
|
155
|
+
return value;
|
|
156
|
+
}
|
|
157
|
+
function finite(value) {
|
|
158
|
+
requireFact(typeof value === 'number' && Number.isFinite(value));
|
|
159
|
+
return value;
|
|
160
|
+
}
|
|
161
|
+
function string(value, nonempty = false) {
|
|
162
|
+
requireFact(typeof value === 'string' && (!nonempty || value.length > 0));
|
|
163
|
+
return value;
|
|
164
|
+
}
|
|
165
|
+
function json(value) {
|
|
166
|
+
try {
|
|
167
|
+
return JSON.parse(string(value));
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
throw invalidArtifact('row contains invalid JSON.');
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function object(value) {
|
|
174
|
+
requireFact(typeof value === 'object' && value !== null && !Array.isArray(value));
|
|
175
|
+
return value;
|
|
176
|
+
}
|
|
177
|
+
function shape(value, required, optional = []) {
|
|
178
|
+
const result = object(value);
|
|
179
|
+
requireFact(required.every(k => Object.hasOwn(result, k)) && Object.keys(result).every(k => required.includes(k) || optional.includes(k)));
|
|
180
|
+
return result;
|
|
181
|
+
}
|
|
182
|
+
function strings(value) {
|
|
183
|
+
requireFact(Array.isArray(value) && value.every(v => typeof v === 'string'));
|
|
184
|
+
return value;
|
|
185
|
+
}
|
|
186
|
+
function decoded(value, nonempty = false) {
|
|
187
|
+
const result = string(json(value), nonempty);
|
|
188
|
+
requireFact(encode(result) === value, 'string encoding is malformed.');
|
|
189
|
+
return result;
|
|
190
|
+
}
|
|
191
|
+
function immutable(value) {
|
|
192
|
+
if (value !== null && typeof value === 'object') {
|
|
193
|
+
for (const item of Object.values(value))
|
|
194
|
+
immutable(item);
|
|
195
|
+
Object.freeze(value);
|
|
196
|
+
}
|
|
197
|
+
return value;
|
|
198
|
+
}
|
|
199
|
+
const kinds = ['join', 'done', 'say', 'hold', 'resume', 'read', 'listen', 'ignore'];
|
|
200
|
+
function head(row) {
|
|
201
|
+
const index = integer(row.act_index), at = finite(row.at);
|
|
202
|
+
requireFact(kinds.includes(string(row.kind)));
|
|
203
|
+
const actor = row.actor_json === null ? undefined : decoded(row.actor_json, true);
|
|
204
|
+
requireFact(actor === undefined ? row.actor_key_json === null && ['hold', 'resume'].includes(row.kind) : row.actor_key_json === key(actor));
|
|
205
|
+
requireFact(row.reach === null || row.kind === 'say' && row.reach === 'bell');
|
|
206
|
+
if (row.reply_index !== null)
|
|
207
|
+
requireFact(row.kind === 'say' && integer(row.reply_index) < index);
|
|
208
|
+
return immutable({
|
|
209
|
+
index, at, kind: row.kind, ...(actor === undefined ? {} : { actor }), ...(row.reach === null ? {} : { reach: 'bell' }), ...(row.reply_index === null ? {} : { reply: row.reply_index })
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
export function validateStoredActivity(value) {
|
|
213
|
+
const a = object(value);
|
|
214
|
+
integer(a.index);
|
|
215
|
+
finite(a.at);
|
|
216
|
+
requireFact(kinds.includes(string(a.kind)));
|
|
217
|
+
const common = ['kind', 'at', 'index'];
|
|
218
|
+
if (a.kind !== 'hold' && a.kind !== 'resume') {
|
|
219
|
+
common.push('actor');
|
|
220
|
+
string(a.actor, true);
|
|
221
|
+
}
|
|
222
|
+
else if (Object.hasOwn(a, 'actor'))
|
|
223
|
+
string(a.actor, true);
|
|
224
|
+
switch (a.kind) {
|
|
225
|
+
case 'say':
|
|
226
|
+
shape(a, [...common, 'body'], ['mentions', 'reach', 'reply']);
|
|
227
|
+
string(a.body);
|
|
228
|
+
if (Object.hasOwn(a, 'mentions'))
|
|
229
|
+
strings(a.mentions);
|
|
230
|
+
if (Object.hasOwn(a, 'reach'))
|
|
231
|
+
requireFact(a.reach === 'bell');
|
|
232
|
+
if (Object.hasOwn(a, 'reply'))
|
|
233
|
+
requireFact(integer(a.reply) < a.index);
|
|
234
|
+
break;
|
|
235
|
+
case 'done':
|
|
236
|
+
case 'hold':
|
|
237
|
+
shape(a, common, ['body', ...(a.kind === 'hold' ? ['actor'] : [])]);
|
|
238
|
+
if (Object.hasOwn(a, 'body'))
|
|
239
|
+
string(a.body);
|
|
240
|
+
break;
|
|
241
|
+
case 'read':
|
|
242
|
+
shape(a, [...common, 'through']);
|
|
243
|
+
integer(a.through);
|
|
244
|
+
break;
|
|
245
|
+
case 'listen':
|
|
246
|
+
case 'ignore':
|
|
247
|
+
shape(a, [...common, 'target']);
|
|
248
|
+
string(a.target, true);
|
|
249
|
+
break;
|
|
250
|
+
default: shape(a, common, a.kind === 'resume' ? ['actor'] : []);
|
|
251
|
+
}
|
|
252
|
+
return value;
|
|
253
|
+
}
|
|
254
|
+
function activity(row) {
|
|
255
|
+
const h = head(row), payload = object(json(row.payload_json));
|
|
256
|
+
// Payload cannot shadow indexed structural columns.
|
|
257
|
+
const allowed = h.kind === 'say' ? ['body', 'mentions'] : h.kind === 'done' || h.kind === 'hold' ? ['body'] : h.kind === 'read' ? ['through'] : h.kind === 'listen' || h.kind === 'ignore' ? ['target'] : [];
|
|
258
|
+
requireFact(Object.keys(payload).every(k => allowed.includes(k)));
|
|
259
|
+
return immutable(validateStoredActivity({ ...h, ...payload }));
|
|
260
|
+
}
|
|
261
|
+
function participant(row) {
|
|
262
|
+
const name = decoded(row.name_json, true);
|
|
263
|
+
requireFact(row.name_key_json === key(name) && [0, 1].includes(row.joined) && [0, 1].includes(row.done) && !(row.joined && row.done));
|
|
264
|
+
return immutable({
|
|
265
|
+
name, firstIndex: integer(row.first_index), joined: row.joined === 1, done: row.done === 1, sayCount: integer(row.say_count), ...(row.last_active_at === null ? {} : { lastActiveAt: finite(row.last_active_at) }), ...(row.last_join_index === null ? {} : { lastJoinIndex: integer(row.last_join_index) })
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
function attention(row) {
|
|
269
|
+
const target = decoded(row.target_json, true), listener = decoded(row.name_json, true);
|
|
270
|
+
requireFact(row.target_key_json === key(target) && row.listener_key_json === key(listener) && ['listen', 'ignore'].includes(row.kind));
|
|
271
|
+
return immutable({
|
|
272
|
+
listener, target, kind: row.kind, sinceIndex: integer(row.since_index)
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
function routeKind(value) {
|
|
276
|
+
requireFact(['mention', 'reply', 'attention', 'bell'].includes(string(value)));
|
|
277
|
+
return value;
|
|
278
|
+
}
|
|
279
|
+
export function validateWatchLease(value) {
|
|
280
|
+
const l = shape(value, ['leaseId', 'heartbeatAt', 'expiresAt'], ['filter']);
|
|
281
|
+
string(l.leaseId, true);
|
|
282
|
+
requireFact(finite(l.expiresAt) >= finite(l.heartbeatAt));
|
|
283
|
+
if (Object.hasOwn(l, 'filter')) {
|
|
284
|
+
const f = shape(l.filter, [], ['participants', 'mention']);
|
|
285
|
+
if (Object.hasOwn(f, 'participants'))
|
|
286
|
+
strings(f.participants);
|
|
287
|
+
if (Object.hasOwn(f, 'mention'))
|
|
288
|
+
string(f.mention);
|
|
289
|
+
}
|
|
290
|
+
return value;
|
|
291
|
+
}
|
|
292
|
+
// Sorted object keys express small-value structural equality, never whole-state comparison.
|
|
293
|
+
function stableJson(value) {
|
|
294
|
+
if (value === null || typeof value !== 'object') {
|
|
295
|
+
requireFact(value === null || typeof value === 'string' || typeof value === 'boolean' || typeof value === 'number' && Number.isFinite(value), 'route contains a non-finite or unsupported JSON value.');
|
|
296
|
+
return encode(value);
|
|
297
|
+
}
|
|
298
|
+
if (Array.isArray(value))
|
|
299
|
+
return '[' + value.map(stableJson).join(',') + ']';
|
|
300
|
+
return '{' + Object.keys(value).sort().map(k => encode(k) + ':' + stableJson(value[k])).join(',') + '}';
|
|
301
|
+
}
|
|
302
|
+
export function validateRoute(value) {
|
|
303
|
+
const r = shape(value, ['location', 'participant', 'sessionId', 'channel', 'kind', 'address', 'updatedAt', 'extra'], ['epoch']);
|
|
304
|
+
for (const k of ['location', 'participant', 'sessionId', 'channel'])
|
|
305
|
+
string(r[k]);
|
|
306
|
+
requireFact(isWakeRouteKind(r.kind));
|
|
307
|
+
finite(r.updatedAt);
|
|
308
|
+
requireFact(Object.values(object(r.address)).every(v => typeof v === 'string'));
|
|
309
|
+
const extra = object(r.extra);
|
|
310
|
+
requireFact(Object.keys(extra).every(k => !['location', 'participant', 'sessionId', 'channel', 'kind', 'address', 'updatedAt', 'epoch'].includes(k)));
|
|
311
|
+
stableJson(extra);
|
|
312
|
+
if (Object.hasOwn(r, 'epoch'))
|
|
313
|
+
stableJson(r.epoch);
|
|
314
|
+
return value;
|
|
315
|
+
}
|
|
316
|
+
function route(row) {
|
|
317
|
+
integer(row.position);
|
|
318
|
+
const payload = shape(json(row.payload_json), ['location', 'participant', 'channel', 'address', 'extra'], ['epoch']);
|
|
319
|
+
requireFact(row.participant_key_json === key(string(payload.participant)));
|
|
320
|
+
return immutable(validateRoute({
|
|
321
|
+
...payload, sessionId: decoded(row.session_json), kind: row.kind, updatedAt: row.updated_at
|
|
322
|
+
}));
|
|
323
|
+
}
|
|
324
|
+
function normalizedSql(sql) { return sql.trim().replace(/;$/, '').replace(/\s+/g, ' ').replace(/\s*([(),=])\s*/g, '$1'); }
|
|
325
|
+
export function validateSqliteHeader(db, location = '') {
|
|
326
|
+
const identity = db.prepare('SELECT application_id,user_version FROM pragma_application_id,pragma_user_version').get();
|
|
327
|
+
requireFact(identity.application_id === APPLICATION_ID, 'unrelated SQLite database.');
|
|
328
|
+
if (identity.user_version === 1) {
|
|
329
|
+
validateLegacyFraming(db, APPLICATION_ID, location);
|
|
330
|
+
throw new SquareError('invalid_args', `Square artifact needs migration.\n${migrationRecoveryCommand(location)}`, { reason: 'migration_required' });
|
|
331
|
+
}
|
|
332
|
+
requireFact(identity.user_version === 2, 'unsupported SQLite format version.');
|
|
333
|
+
const expected = SCHEMA_SQL.split(';').map(s => s.trim()).filter(Boolean).map(normalizedSql).sort();
|
|
334
|
+
const actual = db.prepare("SELECT sql FROM sqlite_schema WHERE name NOT GLOB 'sqlite_*' AND sql IS NOT NULL").all().map(r => normalizedSql(string(r.sql))).sort();
|
|
335
|
+
requireFact(actual.length === expected.length && actual.every((sql, i) => sql === expected[i]), 'unsupported row schema.');
|
|
336
|
+
requireFact(db.prepare('PRAGMA journal_mode').get().journal_mode === (location === ':memory:' ? 'memory' : 'delete'), 'unsupported journal mode.');
|
|
337
|
+
createSqliteDriver(db).header();
|
|
338
|
+
}
|
|
339
|
+
function activitySelection(filter, alias = 'a') {
|
|
340
|
+
const terms = [], args = [];
|
|
341
|
+
const add = (sql, value) => {
|
|
342
|
+
terms.push(sql);
|
|
343
|
+
args.push(value);
|
|
344
|
+
};
|
|
345
|
+
if (filter.afterIndex !== undefined)
|
|
346
|
+
add(`${alias}.act_index > ?`, integer(filter.afterIndex, -1));
|
|
347
|
+
if (filter.beforeIndex !== undefined)
|
|
348
|
+
add(`${alias}.act_index < ?`, integer(filter.beforeIndex));
|
|
349
|
+
if (filter.throughIndex !== undefined)
|
|
350
|
+
add(`${alias}.act_index <= ?`, integer(filter.throughIndex, -1));
|
|
351
|
+
if (filter.afterAt !== undefined)
|
|
352
|
+
add(`${alias}.at > ?`, finite(filter.afterAt));
|
|
353
|
+
if (filter.actorRequired)
|
|
354
|
+
terms.push(`${alias}.actor_json IS NOT NULL`);
|
|
355
|
+
for (const [column, values] of [['act_index', filter.indexes], ['kind', filter.kinds], [filter.actorMatch === 'exact' ? 'actor_json' : 'actor_key_json', filter.actors?.map(name => filter.actorMatch === 'exact' ? encode(name) : key(name))]]) {
|
|
356
|
+
if (values !== undefined) {
|
|
357
|
+
if (column === 'act_index')
|
|
358
|
+
values.forEach(v => integer(v));
|
|
359
|
+
add(`${alias}.${column} IN (SELECT value FROM json_each(?))`, encode(values));
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return { sql: terms.length ? terms.join(' AND ') : '1', args };
|
|
363
|
+
}
|
|
364
|
+
function pageRows(rows, page, decode) {
|
|
365
|
+
const hasMore = rows.length > page.limit;
|
|
366
|
+
if (hasMore)
|
|
367
|
+
rows.pop();
|
|
368
|
+
if (page.edge === 'newest')
|
|
369
|
+
rows.reverse();
|
|
370
|
+
return immutable({ items: rows.map(decode), hasMore });
|
|
371
|
+
}
|
|
372
|
+
function pageLimit(page) {
|
|
373
|
+
requireFact(page.edge === 'oldest' || page.edge === 'newest');
|
|
374
|
+
return Math.min(integer(page.limit, 1) + 1, Number.MAX_SAFE_INTEGER);
|
|
375
|
+
}
|
|
376
|
+
export function createSqliteDriver(db) {
|
|
377
|
+
let changed = false;
|
|
378
|
+
const all = (sql, args = []) => db.prepare(sql).all(...args);
|
|
379
|
+
const one = (sql, args = []) => db.prepare(sql).get(...args);
|
|
380
|
+
const run = (sql, args = []) => Number(db.prepare(sql).run(...args).changes);
|
|
381
|
+
const owner = (requested) => read.participant(requested)?.name ?? requested;
|
|
382
|
+
const attentionRows = (condition, args) => all(`SELECT t.*,p.name_json FROM attention t JOIN participants p ON p.name_key_json=t.listener_key_json WHERE ${condition} ORDER BY t.since_index`, args).map(attention);
|
|
383
|
+
function directedSelection(recipient, filter) {
|
|
384
|
+
const p = read.participant(recipient), base = activitySelection({
|
|
385
|
+
afterIndex: filter.afterIndex, beforeIndex: filter.beforeIndex, throughIndex: filter.throughIndex, actors: filter.senders
|
|
386
|
+
});
|
|
387
|
+
base.sql += ' AND d.recipient_key_json=?';
|
|
388
|
+
base.args.push(p ? key(p.name) : '');
|
|
389
|
+
if (filter.unseenOnly) {
|
|
390
|
+
base.sql += ' AND NOT EXISTS (SELECT 1 FROM receipts r WHERE r.owner_json=? AND r.act_index=a.act_index)';
|
|
391
|
+
base.args.push(encode(p?.name ?? recipient));
|
|
392
|
+
}
|
|
393
|
+
if (filter.attentionOnly)
|
|
394
|
+
base.sql += " AND d.route IN ('mention','reply','bell')";
|
|
395
|
+
return base;
|
|
396
|
+
}
|
|
397
|
+
function directedHead(row, recipient) {
|
|
398
|
+
const h = head(row);
|
|
399
|
+
requireFact(h.kind === 'say' && h.actor !== undefined);
|
|
400
|
+
return immutable({
|
|
401
|
+
index: h.index, at: h.at, actor: h.actor, recipient, route: routeKind(row.route)
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
const directedFrom = 'landed_audience d JOIN activities a ON a.act_index=d.act_index';
|
|
405
|
+
const pendingFrom = `landed_audience d JOIN activities a ON a.act_index=d.act_index JOIN participants p ON p.name_key_json=d.recipient_key_json`;
|
|
406
|
+
const pendingWhere = `p.joined=1 AND d.act_index>COALESCE(p.last_join_index,-1) AND NOT EXISTS (SELECT 1 FROM receipts r WHERE r.owner_json=p.name_json AND r.act_index=d.act_index)`;
|
|
407
|
+
const read = {
|
|
408
|
+
header() {
|
|
409
|
+
const rows = all('SELECT m.*, (SELECT COALESCE(MAX(act_index),-1) FROM activities) AS last_act_index FROM square_meta m');
|
|
410
|
+
requireFact(rows.length === 1 && rows[0].id === 1, 'header row is malformed.');
|
|
411
|
+
const r = rows[0], nextActIndex = integer(r.next_act_index), lastActIndex = integer(r.last_act_index, -1);
|
|
412
|
+
requireFact(nextActIndex > lastActIndex, 'nextActIndex is behind the activity history.');
|
|
413
|
+
return immutable({
|
|
414
|
+
revision: integer(r.revision), nextActIndex, lastActIndex, hardCap: r.hard_cap === null ? null : integer(r.hard_cap, 1), ...(r.throttle_per_minute === null ? {} : { throttlePerMinute: integer(r.throttle_per_minute, 1) }), preamble: strings(json(r.preamble_json)), warmup: strings(json(r.warmup_json))
|
|
415
|
+
});
|
|
416
|
+
},
|
|
417
|
+
participant(name) {
|
|
418
|
+
const r = one('SELECT * FROM participants WHERE name_key_json=?', [key(name)]);
|
|
419
|
+
return r ? participant(r) : undefined;
|
|
420
|
+
},
|
|
421
|
+
roster: () => immutable(all('SELECT * FROM participants ORDER BY first_index').map(participant)),
|
|
422
|
+
attention: name => immutable(attentionRows('t.listener_key_json=?', [key(name)])),
|
|
423
|
+
incomingAttention: name => immutable(attentionRows('t.target_key_json=?', [key(name)])),
|
|
424
|
+
hold() {
|
|
425
|
+
const r = one("SELECT * FROM activities WHERE kind IN ('hold','resume') ORDER BY act_index DESC LIMIT 1");
|
|
426
|
+
if (!r)
|
|
427
|
+
return immutable({ active: false });
|
|
428
|
+
const a = activity(r);
|
|
429
|
+
if (a.kind === 'resume')
|
|
430
|
+
return immutable({ active: false });
|
|
431
|
+
requireFact(a.kind === 'hold');
|
|
432
|
+
return immutable({
|
|
433
|
+
active: true, at: a.at, ...(a.body === undefined ? {} : { reason: a.body }), ...(a.actor === undefined ? {} : { actor: a.actor })
|
|
434
|
+
});
|
|
435
|
+
},
|
|
436
|
+
activity(index) {
|
|
437
|
+
const r = one('SELECT * FROM activities WHERE act_index=?', [integer(index)]);
|
|
438
|
+
return r ? activity(r) : undefined;
|
|
439
|
+
},
|
|
440
|
+
activityHeads(filter, page) {
|
|
441
|
+
const s = activitySelection(filter), limit = pageLimit(page);
|
|
442
|
+
return pageRows(all(`SELECT a.act_index,a.at,a.kind,a.actor_json,a.actor_key_json,a.reach,a.reply_index FROM activities a WHERE ${s.sql} ORDER BY a.act_index ${page.edge === 'newest' ? 'DESC' : 'ASC'} LIMIT ?`, [...s.args, limit]), page, head);
|
|
443
|
+
},
|
|
444
|
+
activities(indexes) {
|
|
445
|
+
indexes.forEach(v => integer(v));
|
|
446
|
+
return immutable(all('SELECT * FROM activities WHERE act_index IN (SELECT value FROM json_each(?)) ORDER BY act_index', [encode(indexes)]).map(activity));
|
|
447
|
+
},
|
|
448
|
+
activityCount(filter) {
|
|
449
|
+
const s = activitySelection(filter);
|
|
450
|
+
return integer(one(`SELECT COUNT(*) AS n FROM activities a WHERE ${s.sql}`, s.args).n);
|
|
451
|
+
},
|
|
452
|
+
contextIndexes(centers, before, after) {
|
|
453
|
+
integer(before);
|
|
454
|
+
integer(after);
|
|
455
|
+
const result = new Set();
|
|
456
|
+
for (const center of centers) {
|
|
457
|
+
integer(center);
|
|
458
|
+
if (!one('SELECT act_index FROM activities WHERE act_index=?', [center]))
|
|
459
|
+
continue;
|
|
460
|
+
result.add(center);
|
|
461
|
+
for (const r of all('SELECT act_index FROM activities WHERE act_index<? ORDER BY act_index DESC LIMIT ?', [center, before]))
|
|
462
|
+
result.add(integer(r.act_index));
|
|
463
|
+
for (const r of all('SELECT act_index FROM activities WHERE act_index>? ORDER BY act_index LIMIT ?', [center, after]))
|
|
464
|
+
result.add(integer(r.act_index));
|
|
465
|
+
}
|
|
466
|
+
return immutable([...result].sort((a, b) => a - b));
|
|
467
|
+
},
|
|
468
|
+
sayOrdinals(indexes) {
|
|
469
|
+
indexes.forEach(v => integer(v));
|
|
470
|
+
const result = {};
|
|
471
|
+
for (const r of all("SELECT a.act_index,(SELECT COUNT(*) FROM activities s WHERE s.kind='say' AND s.actor_key_json=a.actor_key_json AND s.act_index<=a.act_index) AS n FROM activities a WHERE a.kind='say' AND a.act_index IN (SELECT value FROM json_each(?))", [encode(indexes)]))
|
|
472
|
+
result[integer(r.act_index)] = integer(r.n, 1);
|
|
473
|
+
return immutable(result);
|
|
474
|
+
},
|
|
475
|
+
recentSayTimes(after, take, actor, bell) {
|
|
476
|
+
const args = [finite(after)];
|
|
477
|
+
let where = "kind='say' AND at>?";
|
|
478
|
+
if (actor !== undefined) {
|
|
479
|
+
where += ' AND actor_key_json=?';
|
|
480
|
+
args.push(key(actor));
|
|
481
|
+
}
|
|
482
|
+
if (bell)
|
|
483
|
+
where += " AND reach='bell'";
|
|
484
|
+
args.push(integer(take));
|
|
485
|
+
return immutable(all(`SELECT at FROM activities WHERE ${where} ORDER BY at DESC,act_index DESC LIMIT ?`, args).map(r => finite(r.at)).reverse());
|
|
486
|
+
},
|
|
487
|
+
audience(index) { return immutable(all('SELECT p.name_json,d.route FROM landed_audience d JOIN participants p ON p.name_key_json=d.recipient_key_json WHERE d.act_index=? ORDER BY d.recipient_order', [integer(index)]).map(r => ({ recipient: decoded(r.name_json, true), route: routeKind(r.route) }))); },
|
|
488
|
+
directed(recipient, filter, page) {
|
|
489
|
+
const s = directedSelection(recipient, filter), limit = pageLimit(page), canonical = owner(recipient);
|
|
490
|
+
return pageRows(all(`SELECT a.act_index,a.at,a.kind,a.actor_json,a.actor_key_json,a.reach,a.reply_index,d.route FROM ${directedFrom} WHERE ${s.sql} ORDER BY a.act_index ${page.edge === 'newest' ? 'DESC' : 'ASC'} LIMIT ?`, [...s.args, limit]), page, r => directedHead(r, canonical));
|
|
491
|
+
},
|
|
492
|
+
directedAt(recipient, index) { return read.directed(recipient, { afterIndex: integer(index) - 1, throughIndex: index }, { edge: 'oldest', limit: 1 }).items[0]; },
|
|
493
|
+
directedCount(recipient, filter) {
|
|
494
|
+
const s = directedSelection(recipient, filter);
|
|
495
|
+
return integer(one(`SELECT COUNT(*) AS n FROM ${directedFrom} WHERE ${s.sql}`, s.args).n);
|
|
496
|
+
},
|
|
497
|
+
pendingAudience(after, through, limit) {
|
|
498
|
+
const args = [integer(after, -1), integer(through, -1)], extra = pageLimit({ edge: 'oldest', limit });
|
|
499
|
+
const indices = all(`SELECT DISTINCT d.act_index FROM ${pendingFrom} WHERE ${pendingWhere} AND d.act_index>? AND d.act_index<=? ORDER BY d.act_index LIMIT ?`, [...args, extra]);
|
|
500
|
+
const hasMore = indices.length > limit;
|
|
501
|
+
if (hasMore)
|
|
502
|
+
indices.pop();
|
|
503
|
+
const groups = indices.map(r => {
|
|
504
|
+
const rows = all(`SELECT a.act_index,a.at,a.kind,a.actor_json,a.actor_key_json,a.reach,a.reply_index,p.name_json,d.route FROM ${pendingFrom} WHERE ${pendingWhere} AND d.act_index=? ORDER BY d.recipient_order`, [integer(r.act_index)]);
|
|
505
|
+
const h = head(rows[0]);
|
|
506
|
+
requireFact(h.kind === 'say' && h.actor !== undefined);
|
|
507
|
+
return {
|
|
508
|
+
index: h.index, at: h.at, actor: h.actor, recipients: rows.map(v => ({ recipient: decoded(v.name_json, true), route: routeKind(v.route) }))
|
|
509
|
+
};
|
|
510
|
+
});
|
|
511
|
+
return immutable({ groups, hasMore });
|
|
512
|
+
},
|
|
513
|
+
receipt(name, index) {
|
|
514
|
+
const r = one('SELECT at FROM receipts WHERE owner_json=? AND act_index=?', [encode(owner(name)), integer(index)]);
|
|
515
|
+
return r ? immutable({ at: finite(r.at) }) : undefined;
|
|
516
|
+
},
|
|
517
|
+
receiptFacts(name, afterIndex) {
|
|
518
|
+
const k = encode(owner(name)), r = one('SELECT MAX(at) AS latest FROM receipts WHERE owner_json=?', [k]), e = one('SELECT MAX(r.act_index) AS latest FROM receipts r JOIN activities a ON a.act_index=r.act_index WHERE r.owner_json=? AND r.act_index>? AND a.actor_json IS NOT NULL', [k, integer(afterIndex, -1)]);
|
|
519
|
+
return immutable({ ...(r.latest === null ? {} : { latestAt: finite(r.latest) }), ...(e.latest === null ? {} : { latestActorActivityIndex: integer(e.latest) }) });
|
|
520
|
+
},
|
|
521
|
+
receiptAssignments: () => immutable(all('SELECT owner_json,act_index FROM receipts ORDER BY owner_json,act_index').map(r => ({ owner: decoded(r.owner_json, true), index: integer(r.act_index) }))),
|
|
522
|
+
lease(name) {
|
|
523
|
+
const r = one('SELECT * FROM catch_leases WHERE owner_json=?', [encode(owner(name))]);
|
|
524
|
+
return r ? immutable(validateWatchLease({
|
|
525
|
+
leaseId: decoded(r.lease_id_json, true), heartbeatAt: finite(r.heartbeat_at), expiresAt: finite(r.expires_at), ...(r.filter_json === null ? {} : { filter: json(r.filter_json) })
|
|
526
|
+
})) : undefined;
|
|
527
|
+
},
|
|
528
|
+
routes: () => immutable(all('SELECT * FROM routes ORDER BY position').map(route)),
|
|
529
|
+
};
|
|
530
|
+
const writes = {
|
|
531
|
+
applyLanding(effect) {
|
|
532
|
+
const a = validateStoredActivity(effect.activity);
|
|
533
|
+
requireFact(a.index >= read.header().nextActIndex && a.index < Number.MAX_SAFE_INTEGER, 'activity index cannot advance.');
|
|
534
|
+
const { index, at, kind, actor, reach, reply, ...payload } = a;
|
|
535
|
+
run('INSERT INTO activities(act_index,at,kind,actor_json,actor_key_json,reach,reply_index,payload_json) VALUES(?,?,?,?,?,?,?,?)', [index, at, kind, actor === undefined ? null : encode(actor), actor === undefined ? null : key(actor), reach ?? null, reply ?? null, encode(payload)]);
|
|
536
|
+
if (effect.participant) {
|
|
537
|
+
const p = effect.participant;
|
|
538
|
+
string(p.name, true);
|
|
539
|
+
integer(p.firstIndex);
|
|
540
|
+
integer(p.sayCount);
|
|
541
|
+
requireFact(typeof p.joined === 'boolean' && typeof p.done === 'boolean' && !(p.joined && p.done));
|
|
542
|
+
if (p.lastActiveAt !== undefined)
|
|
543
|
+
finite(p.lastActiveAt);
|
|
544
|
+
if (p.lastJoinIndex !== undefined)
|
|
545
|
+
integer(p.lastJoinIndex);
|
|
546
|
+
run('INSERT INTO participants(name_key_json,name_json,first_index,joined,done,say_count,last_active_at,last_join_index) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(name_key_json) DO UPDATE SET joined=excluded.joined,done=excluded.done,say_count=excluded.say_count,last_active_at=excluded.last_active_at,last_join_index=excluded.last_join_index', [key(p.name), encode(p.name), p.firstIndex, Number(p.joined), Number(p.done), p.sayCount, p.lastActiveAt ?? null, p.lastJoinIndex ?? null]);
|
|
547
|
+
}
|
|
548
|
+
if (effect.removeListeningOf !== undefined)
|
|
549
|
+
run("DELETE FROM attention WHERE listener_key_json=? AND kind='listen'", [key(effect.removeListeningOf)]);
|
|
550
|
+
if (effect.replaceAttention) {
|
|
551
|
+
const t = effect.replaceAttention;
|
|
552
|
+
string(t.listener, true);
|
|
553
|
+
string(t.target, true);
|
|
554
|
+
integer(t.sinceIndex);
|
|
555
|
+
requireFact(t.kind === 'listen' || t.kind === 'ignore');
|
|
556
|
+
run('INSERT INTO attention(listener_key_json,target_key_json,target_json,kind,since_index) VALUES(?,?,?,?,?) ON CONFLICT(listener_key_json,target_key_json) DO UPDATE SET target_json=excluded.target_json,kind=excluded.kind,since_index=excluded.since_index', [key(t.listener), key(t.target), encode(t.target), t.kind, t.sinceIndex]);
|
|
557
|
+
}
|
|
558
|
+
effect.audience.forEach((r, order) => run('INSERT INTO landed_audience(act_index,recipient_key_json,recipient_order,route) VALUES(?,?,?,?)', [index, key(r.recipient), order, r.route]));
|
|
559
|
+
run('UPDATE square_meta SET next_act_index=? WHERE id=1', [index + 1]);
|
|
560
|
+
changed = true;
|
|
561
|
+
},
|
|
562
|
+
markSeen(name, indexes, at) {
|
|
563
|
+
finite(at);
|
|
564
|
+
const next = read.header().nextActIndex;
|
|
565
|
+
indexes.forEach(index => requireFact(integer(index) < next, 'receipt references an unassigned activity index.'));
|
|
566
|
+
let count = 0;
|
|
567
|
+
const encoded = encode(owner(name));
|
|
568
|
+
string(owner(name), true);
|
|
569
|
+
for (const index of indexes)
|
|
570
|
+
count += run('INSERT OR IGNORE INTO receipts(owner_json,act_index,at) VALUES(?,?,?)', [encoded, index, at]);
|
|
571
|
+
if (count)
|
|
572
|
+
changed = true;
|
|
573
|
+
return count;
|
|
574
|
+
},
|
|
575
|
+
putLease(name, value) {
|
|
576
|
+
const l = validateWatchLease(value), old = read.lease(name);
|
|
577
|
+
if (old && stableJson(old) === stableJson(l))
|
|
578
|
+
return false;
|
|
579
|
+
const canonical = owner(name);
|
|
580
|
+
string(canonical, true);
|
|
581
|
+
run('INSERT INTO catch_leases(owner_json,lease_id_json,heartbeat_at,expires_at,filter_json) VALUES(?,?,?,?,?) ON CONFLICT(owner_json) DO UPDATE SET lease_id_json=excluded.lease_id_json,heartbeat_at=excluded.heartbeat_at,expires_at=excluded.expires_at,filter_json=excluded.filter_json', [encode(canonical), encode(l.leaseId), l.heartbeatAt, l.expiresAt, l.filter === undefined ? null : encode(l.filter)]);
|
|
582
|
+
changed = true;
|
|
583
|
+
return true;
|
|
584
|
+
},
|
|
585
|
+
removeLease(name, expected) {
|
|
586
|
+
const count = run('DELETE FROM catch_leases WHERE owner_json=? AND lease_id_json=?', [encode(owner(name)), encode(expected)]);
|
|
587
|
+
if (count)
|
|
588
|
+
changed = true;
|
|
589
|
+
return count > 0;
|
|
590
|
+
},
|
|
591
|
+
replaceRoutes(values) {
|
|
592
|
+
values.forEach(validateRoute);
|
|
593
|
+
if (stableJson(values) === stableJson(read.routes()))
|
|
594
|
+
return false;
|
|
595
|
+
run('DELETE FROM routes');
|
|
596
|
+
values.forEach((r, position) => {
|
|
597
|
+
const { sessionId, kind, updatedAt, extra, ...payload } = r;
|
|
598
|
+
run('INSERT INTO routes(position,participant_key_json,session_json,kind,updated_at,payload_json) VALUES(?,?,?,?,?,?)', [position, key(r.participant), encode(sessionId), kind, updatedAt, encode({ ...payload, extra })]);
|
|
599
|
+
});
|
|
600
|
+
changed = true;
|
|
601
|
+
return true;
|
|
602
|
+
},
|
|
603
|
+
};
|
|
604
|
+
return Object.defineProperty(Object.assign(read, writes), 'changed', { get: () => changed });
|
|
605
|
+
}
|
|
606
|
+
export function legacyRouteFact(value) {
|
|
607
|
+
const { location, participant, sessionId, channel, kind, address, updatedAt, epoch, ...extra } = value;
|
|
608
|
+
return validateRoute({
|
|
609
|
+
location, participant, sessionId, channel, kind, address, updatedAt, ...(Object.hasOwn(value, 'epoch') ? { epoch } : {}), extra
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
/** Explicit import only. Caller owns transaction, legacy validation and format publication. */
|
|
613
|
+
export function initializeSqlite(db, state, revision = 0) {
|
|
614
|
+
// Reject unsupported routes BEFORE any schema changes, also for direct fixture callers.
|
|
615
|
+
const routes = (state.routes ?? []).map(r => legacyRouteFact(r));
|
|
616
|
+
integer(revision);
|
|
617
|
+
integer(state.runtime.nextActIndex);
|
|
618
|
+
if (state.hardCap !== null)
|
|
619
|
+
integer(state.hardCap, 1);
|
|
620
|
+
if (state.throttlePerMinute !== undefined)
|
|
621
|
+
integer(state.throttlePerMinute, 1);
|
|
622
|
+
db.exec(SCHEMA_SQL);
|
|
623
|
+
db.prepare('INSERT INTO square_meta(id,revision,next_act_index,hard_cap,throttle_per_minute,preamble_json,warmup_json) VALUES(1,?,0,?,?,?,?)').run(revision, state.hardCap, state.throttlePerMinute ?? null, encode(strings(state.preamble)), encode(strings(state.warmup)));
|
|
624
|
+
const driver = createSqliteDriver(db);
|
|
625
|
+
for (const activity of state.acts)
|
|
626
|
+
driver.applyLanding(deriveLanding(validateStoredActivity(activity), landingFacts(driver, activity)));
|
|
627
|
+
requireFact(state.runtime.nextActIndex > driver.header().lastActIndex, 'nextActIndex is behind the activity history.');
|
|
628
|
+
db.prepare('UPDATE square_meta SET next_act_index=? WHERE id=1').run(state.runtime.nextActIndex);
|
|
629
|
+
const insert = db.prepare('INSERT INTO receipts(owner_json,act_index,at) VALUES(?,?,?)');
|
|
630
|
+
for (const [owner, observations] of Object.entries(state.runtime.observations)) {
|
|
631
|
+
string(owner, true);
|
|
632
|
+
for (const [id, observation] of Object.entries(observations)) {
|
|
633
|
+
const match = /^act\/(0|[1-9][0-9]*)$/.exec(id);
|
|
634
|
+
requireFact(match, 'malformed receipt coordinate.');
|
|
635
|
+
const index = integer(Number(match[1]));
|
|
636
|
+
requireFact(index < state.runtime.nextActIndex, 'receipt references an unassigned activity index.');
|
|
637
|
+
requireFact(observation.state === 'seen');
|
|
638
|
+
insert.run(encode(owner), index, finite(observation.at));
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
for (const [owner, value] of Object.entries(state.runtime.leases)) {
|
|
642
|
+
string(owner, true);
|
|
643
|
+
const l = validateWatchLease(value);
|
|
644
|
+
db.prepare('INSERT INTO catch_leases(owner_json,lease_id_json,heartbeat_at,expires_at,filter_json) VALUES(?,?,?,?,?)').run(encode(owner), encode(l.leaseId), l.heartbeatAt, l.expiresAt, l.filter === undefined ? null : encode(l.filter));
|
|
645
|
+
}
|
|
646
|
+
driver.replaceRoutes(routes);
|
|
647
|
+
}
|
|
648
|
+
/** Exceptional export projection for fixtures, never a runtime fallback. */
|
|
649
|
+
export function exportSqliteState(db) {
|
|
650
|
+
const d = createSqliteDriver(db), h = d.header();
|
|
651
|
+
const observations = {}, leases = {};
|
|
652
|
+
for (const row of db.prepare('SELECT * FROM receipts').all()) {
|
|
653
|
+
const owner = decoded(row.owner_json, true), index = integer(row.act_index);
|
|
654
|
+
requireFact(index < h.nextActIndex, 'receipt references an unassigned activity index.');
|
|
655
|
+
if (!Object.hasOwn(observations, owner))
|
|
656
|
+
Object.defineProperty(observations, owner, {
|
|
657
|
+
value: {}, enumerable: true, writable: true
|
|
658
|
+
});
|
|
659
|
+
observations[owner][`act/${index}`] = { state: 'seen', at: finite(row.at) };
|
|
660
|
+
}
|
|
661
|
+
for (const row of db.prepare('SELECT * FROM catch_leases').all()) {
|
|
662
|
+
const owner = decoded(row.owner_json, true);
|
|
663
|
+
Object.defineProperty(leases, owner, { value: validateWatchLease({
|
|
664
|
+
leaseId: decoded(row.lease_id_json, true), heartbeatAt: finite(row.heartbeat_at), expiresAt: finite(row.expires_at), ...(row.filter_json === null ? {} : { filter: json(row.filter_json) })
|
|
665
|
+
}), enumerable: true });
|
|
666
|
+
}
|
|
667
|
+
return {
|
|
668
|
+
hardCap: h.hardCap, ...(h.throttlePerMinute === undefined ? {} : { throttlePerMinute: h.throttlePerMinute }), preamble: [...h.preamble], warmup: [...h.warmup], acts: db.prepare('SELECT * FROM activities ORDER BY act_index').all().map(activity), routes: d.routes().map(r => {
|
|
669
|
+
const { extra, ...fields } = r;
|
|
670
|
+
return { ...fields, ...extra };
|
|
671
|
+
}), runtime: {
|
|
672
|
+
nextActIndex: h.nextActIndex, observations, leases
|
|
673
|
+
}
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
/** Full read-only integrity inspection, including maintained-index replay comparison. */
|
|
677
|
+
export function inspectSqlite(db) {
|
|
678
|
+
const check = db.prepare('PRAGMA integrity_check').all();
|
|
679
|
+
requireFact(check.length === 1 && check[0].integrity_check === 'ok', 'SQLite integrity check failed.');
|
|
680
|
+
const state = exportSqliteState(db), d = createSqliteDriver(db);
|
|
681
|
+
d.roster();
|
|
682
|
+
for (const p of d.roster()) {
|
|
683
|
+
d.attention(p.name);
|
|
684
|
+
d.incomingAttention(p.name);
|
|
685
|
+
}
|
|
686
|
+
for (const a of state.acts)
|
|
687
|
+
d.audience(a.index);
|
|
688
|
+
// A transient memory database checks derived indices without altering authority.
|
|
689
|
+
const rebuilt = new MemoryDatabase(':memory:');
|
|
690
|
+
try {
|
|
691
|
+
initializeSqlite(rebuilt, state, d.header().revision);
|
|
692
|
+
for (const table of ['participants', 'attention', 'landed_audience']) {
|
|
693
|
+
const original = db.prepare(`SELECT * FROM ${table} ORDER BY 1,2`).all();
|
|
694
|
+
const expected = rebuilt.prepare(`SELECT * FROM ${table} ORDER BY 1,2`).all();
|
|
695
|
+
requireFact(stableJson(original) === stableJson(expected), `derived ${table} index is inconsistent.`);
|
|
696
|
+
}
|
|
697
|
+
return { standingCount: d.roster().filter(p => p.joined).length };
|
|
698
|
+
}
|
|
699
|
+
finally {
|
|
700
|
+
rebuilt.close();
|
|
701
|
+
}
|
|
702
|
+
}
|