@astrosheep/square 0.3.80 → 0.3.81

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/artifact.js CHANGED
@@ -9,7 +9,7 @@ import { observeFileVersion } from './file-changes.js';
9
9
  import { canonicalPath } from './canonical-path.js';
10
10
  import { InternalSquareError, SquareError } from './model.js';
11
11
  import { wrapSquareWrite } from './square-domain.js';
12
- import { APPLICATION_ID, createSqliteDriver, initializeSqlite, exportSqliteState, inspectSqlite, invalidArtifact, validateSqliteHeader, emptyRuntimeState } from './square-sqlite.js';
12
+ import { APPLICATION_ID, CURRENT_USER_VERSION, createSqliteDriver, initializeSqlite, exportSqliteState, inspectSqlite, invalidArtifact, validateSqliteHeader, emptyRuntimeState } from './square-sqlite.js';
13
13
  const guideContents = new Map();
14
14
  function readGuide(name) {
15
15
  if (!['participant', 'architect', 'brainstorm'].includes(name))
@@ -107,7 +107,7 @@ export function createArtifactPort(location, initial, externalSignal) {
107
107
  database = new DatabaseSync(':memory:');
108
108
  try {
109
109
  initializeSqlite(database, initial);
110
- database.exec(`PRAGMA application_id=${APPLICATION_ID}; PRAGMA user_version=2`);
110
+ database.exec(`PRAGMA application_id=${APPLICATION_ID}; PRAGMA user_version=${CURRENT_USER_VERSION}`);
111
111
  }
112
112
  catch (error) {
113
113
  database.close();
@@ -270,7 +270,7 @@ async function initializeFile(location, state, signal) {
270
270
  db.exec('PRAGMA journal_mode=DELETE; PRAGMA synchronous=FULL');
271
271
  await inSqliteTransaction(db, true, () => { }, () => {
272
272
  initializeSqlite(db, state);
273
- db.exec(`PRAGMA application_id=${APPLICATION_ID}; PRAGMA user_version=2`);
273
+ db.exec(`PRAGMA application_id=${APPLICATION_ID}; PRAGMA user_version=${CURRENT_USER_VERSION}`);
274
274
  }, signal);
275
275
  }
276
276
  finally {
@@ -93,6 +93,7 @@ const LOCATION_REQUIRED_COMMANDS = new Set([
93
93
  'express',
94
94
  'listen',
95
95
  'ignore',
96
+ 'unlisten',
96
97
  'listening',
97
98
  'done',
98
99
  'hold',
@@ -12,10 +12,10 @@ export const migrateCommand = {
12
12
  const outcome = await migrateSquare(squareInput(context));
13
13
  const body = outcome.status === 'migrated' && outcome.counts !== undefined
14
14
  ? [
15
- '✓ migrated to row storage',
15
+ '✓ migrated to current row storage',
16
16
  ` · ${outcome.counts.activities} activities · ${outcome.counts.receipts} receipts · ${outcome.counts.leases} leases · ${outcome.counts.routes} routes`,
17
17
  ].join('\n')
18
- : '· already format 2 — nothing changed';
18
+ : '· already current format — nothing changed';
19
19
  return { output: withPathOutput(squarePath, body, { participantCount: outcome.standing }) };
20
20
  },
21
21
  present(result) {
@@ -1,4 +1,4 @@
1
- import { buildCommand, doneCommand, expressCommand, holdCommand, ignoreCommand, joinCommand, listenCommand, listeningCommand, resumeCommand } from './square-commands.js';
1
+ import { buildCommand, doneCommand, expressCommand, holdCommand, ignoreCommand, joinCommand, listenCommand, listeningCommand, resumeCommand, unlistenCommand } from './square-commands.js';
2
2
  import { doctorCommand, migrateCommand } from './maintenance-commands.js';
3
3
  import { ledgerCommand } from './ledger-command.js';
4
4
  import { harnessCommand, installCommand, uninstallCommand } from './harness-command.js';
@@ -18,6 +18,7 @@ export const commandRegistry = [
18
18
  { names: ['express'], spec: expressCommand },
19
19
  { names: ['listen'], spec: listenCommand },
20
20
  { names: ['ignore'], spec: ignoreCommand },
21
+ { names: ['unlisten'], spec: unlistenCommand },
21
22
  { names: ['listening'], spec: listeningCommand },
22
23
  { names: ['done'], spec: doneCommand },
23
24
  { names: ['hold'], spec: holdCommand },
@@ -30,6 +30,7 @@ export declare const joinCommand: CommandSpec<JoinIntent, string>;
30
30
  export declare const expressCommand: CommandSpec<ActivityIntent>;
31
31
  export declare const listenCommand: CommandSpec<ListenerIntent, string>;
32
32
  export declare const ignoreCommand: CommandSpec<ListenerIntent, string>;
33
+ export declare const unlistenCommand: CommandSpec<ListenerIntent, string>;
33
34
  export declare const listeningCommand: CommandSpec<ListenerIntent, string>;
34
35
  export declare const doneCommand: CommandSpec<BodyIntent, string>;
35
36
  export declare const holdCommand: CommandSpec<BodyIntent, string>;
@@ -171,8 +171,12 @@ function listenerCommand(verb) {
171
171
  const actor = participantIdentity(projection.participant);
172
172
  const target = participantIdentity(projection.target);
173
173
  const action = data.activity !== null
174
- ? verb === 'listen' ? `${actor} turns an ear toward ${target}` : `${actor} turns away from ${target}`
175
- : verb === 'listen' ? `${actor} already turns an ear toward ${target}` : `${actor} is not turned toward ${target}`;
174
+ ? verb === 'listen' ? `${actor} turns an ear toward ${target}`
175
+ : verb === 'ignore' ? `${actor} turns away from ${target}`
176
+ : `${actor} stops listening to ${target}`
177
+ : verb === 'listen' ? `${actor} already turns an ear toward ${target}`
178
+ : verb === 'ignore' ? `${actor} is not turned toward ${target}`
179
+ : `${actor} is not listening to ${target}`;
176
180
  return withPathOutput(projection.location, `${data.activity === null ? '○' : '·'} ${action}`, {
177
181
  participantCount: projection.standingCount, held: projection.held,
178
182
  });
@@ -182,6 +186,7 @@ function listenerCommand(verb) {
182
186
  }
183
187
  export const listenCommand = listenerCommand('listen');
184
188
  export const ignoreCommand = listenerCommand('ignore');
189
+ export const unlistenCommand = listenerCommand('unlisten');
185
190
  export const listeningCommand = {
186
191
  parse(argv, context) { return parseListener(argv, context, false); },
187
192
  async execute(_intent, context) {
@@ -54,8 +54,9 @@ export declare function decideAct(facts: SquareRead, input: {
54
54
  }): ActDecision;
55
55
  export declare function validateDoneBody(body: unknown): void;
56
56
  export declare function coreDone(facts: SquareRead, name: string, body: string, now: number): TimedAct<'done'>;
57
- export declare function coreListen(facts: SquareRead, actor: string, target: string, now: number): TimedAct<"listen" | "ignore"> | undefined;
58
- export declare function coreIgnore(facts: SquareRead, actor: string, target: string, now: number): TimedAct<"listen" | "ignore"> | undefined;
57
+ export declare function coreListen(facts: SquareRead, actor: string, target: string, now: number): TimedAct<"listen" | "ignore" | "unlisten"> | undefined;
58
+ export declare function coreIgnore(facts: SquareRead, actor: string, target: string, now: number): TimedAct<"listen" | "ignore" | "unlisten"> | undefined;
59
+ export declare function coreUnlisten(facts: SquareRead, actor: string, target: string, now: number): TimedAct<"listen" | "ignore" | "unlisten"> | undefined;
59
60
  export declare function coreListening(facts: SquareRead, actor: string): readonly string[];
60
61
  export declare function coreHold(facts: SquareRead, actor: string, body: string, now: number): TimedAct<'hold'>;
61
62
  export declare function coreResume(facts: SquareRead, actor: string, now: number): TimedAct<'resume'>;
package/dist/decisions.js CHANGED
@@ -171,6 +171,8 @@ function attentionChange(facts, kind, actor, target, now) {
171
171
  requireStanding(current);
172
172
  const outgoing = facts.attention(current.name);
173
173
  const existing = outgoing.find((item) => sameName(item.target, target));
174
+ if (kind === 'unlisten')
175
+ return existing?.kind === 'listen' ? { kind, actor: current.name, target, at: now } : undefined;
174
176
  if (kind === 'listen' && existing?.kind !== 'listen' && outgoing.filter((item) => item.kind === 'listen').length >= MAX_IDENTITY_SET_SIZE) {
175
177
  throw new SquareError('invalid_args', `A participant can listen to at most ${MAX_IDENTITY_SET_SIZE} others`);
176
178
  }
@@ -178,6 +180,7 @@ function attentionChange(facts, kind, actor, target, now) {
178
180
  }
179
181
  export function coreListen(facts, actor, target, now) { return attentionChange(facts, 'listen', actor, target, now); }
180
182
  export function coreIgnore(facts, actor, target, now) { return attentionChange(facts, 'ignore', actor, target, now); }
183
+ export function coreUnlisten(facts, actor, target, now) { return attentionChange(facts, 'unlisten', actor, target, now); }
181
184
  export function coreListening(facts, actor) {
182
185
  const current = resolveStanding(facts, actor);
183
186
  return facts.attention(current.name).filter((item) => item.kind === 'listen').map((item) => item.target);
package/dist/help.js CHANGED
@@ -34,7 +34,8 @@ const COMMANDS = [
34
34
  ' --no-wait If held or throttled, save a draft and return instead of waiting. A held wait gives up on its own after 10 minutes.',
35
35
  ],
36
36
  },
37
- { names: ['listen'], usage: '--as <name> listen <participant>', usesSquare: true, group: 'participant', summary: 'Turn an ear toward one participant\'s future bare says.' },
37
+ { names: ['listen'], usage: '--as <name> listen <participant>', usesSquare: true, group: 'participant', summary: 'Turn an ear toward one participant\'s future activity.' },
38
+ { names: ['unlisten'], usage: '--as <name> unlisten <participant>', usesSquare: true, group: 'participant', summary: 'Stop listening without blocking that participant\'s mentions or replies.' },
38
39
  { names: ['ignore'], usage: '--as <name> ignore <participant>', usesSquare: true, group: 'participant', summary: 'Ignore one participant\'s future mentions and bare says.' },
39
40
  { names: ['listening'], usage: '--as <name> listening', usesSquare: true, group: 'participant', summary: 'Show who you are turned toward.', details: ['Listening is future-only: the edge is fixed when a say lands; it never rewrites history.'] },
40
41
  {
@@ -89,8 +90,8 @@ const COMMANDS = [
89
90
  },
90
91
  {
91
92
  names: ['migrate'], usage: 'migrate', usesSquare: true, group: 'maintenance',
92
- summary: 'Convert an existing version 1 square to row storage in place.',
93
- details: ['For the selected .square artifact only. Global identity and delivery records use square ledger migrate instead.', 'Stop sessions and services using this artifact before conversion. Back up the stopped artifact, migrate it, then run square --location <path> doctor before starting updated clients.', 'A version 2 square is left untouched; an unrelated or unsupported artifact is refused without a write. There is no --check, --backup or --force.'],
93
+ summary: 'Convert an existing version 1 or 2 square to current format 3 in place.',
94
+ details: ['For the selected .square artifact only. Global identity and delivery records use square ledger migrate instead.', 'Stop sessions and services using this artifact before conversion. Back up the stopped artifact, migrate it, then run square --location <path> doctor before starting updated clients.', 'A version 3 square is left untouched; an unrelated or unsupported artifact is refused without a write. There is no --check, --backup or --force.'],
94
95
  },
95
96
  {
96
97
  names: ['ledger'], usage: 'ledger <verify|migrate|gc>', usesSquare: false, group: 'maintenance',
@@ -106,7 +107,7 @@ function isHelpFlag(value) {
106
107
  }
107
108
  export function renderGlobalHelp() {
108
109
  const groups = [
109
- { key: 'participant', title: 'In the square:', order: ['join', 'express', 'listen', 'ignore', 'listening', 'catch', 'history', 'status', 'hold', 'resume', 'done'] },
110
+ { key: 'participant', title: 'In the square:', order: ['join', 'express', 'listen', 'unlisten', 'ignore', 'listening', 'catch', 'history', 'status', 'hold', 'resume', 'done'] },
110
111
  { key: 'host', title: 'Prepare and manage:', order: ['build', 'list', 'participants'] },
111
112
  { key: 'maintenance', title: 'Setup:', order: ['install', 'uninstall', 'doctor', 'migrate', 'ledger'] },
112
113
  ];
package/dist/model.d.ts CHANGED
@@ -99,7 +99,7 @@ export type PublicAct = Extract<StoredAct, {
99
99
  kind: 'say' | 'done';
100
100
  }>;
101
101
  export type RoomChangeAct = Extract<StoredAct, {
102
- kind: 'join' | 'done' | 'hold' | 'resume' | 'listen' | 'ignore';
102
+ kind: 'join' | 'done' | 'hold' | 'resume' | 'listen' | 'ignore' | 'unlisten';
103
103
  }>;
104
104
  export declare function formatHardCap(hardCap: HardCap): string;
105
105
  export declare function parseParticipantList(value: string): string[];
@@ -139,6 +139,8 @@ export function renderRoomChangeText(event) {
139
139
  return `${identity} turned an ear toward ${participantIdentity(event.target ?? 'someone')}`;
140
140
  case 'ignore':
141
141
  return `${identity} turned away from ${participantIdentity(event.target ?? 'someone')}`;
142
+ case 'unlisten':
143
+ return `${identity} stopped listening to ${participantIdentity(event.target ?? 'someone')}`;
142
144
  default:
143
145
  throw new Error('Expected a lifecycle activity');
144
146
  }
@@ -171,6 +173,7 @@ function renderEventCliFacts(event, opts = {}) {
171
173
  return { text: `${style('release', '✓')} ${renderRoomChangeText(event)}`, bodyClipped: false };
172
174
  case 'listen':
173
175
  case 'ignore':
176
+ case 'unlisten':
174
177
  return { text: `· ${renderRoomChangeText(event)}`, bodyClipped: false };
175
178
  case 'say': {
176
179
  const body = renderedBody(event.body, maxBody);
package/dist/runtime.js CHANGED
@@ -48,7 +48,7 @@ export function readCursor(facts, name) {
48
48
  const block = facts.directed(name, { afterIndex: boundary, unseenOnly: true }, { edge: 'oldest', limit: 1 }).items[0]?.index;
49
49
  const last = facts.activityHeads({
50
50
  afterIndex: boundary, ...(block === undefined ? {} : { beforeIndex: block }), actorRequired: true,
51
- kinds: ['join', 'done', 'say', 'hold', 'resume', 'listen', 'ignore'],
51
+ kinds: ['join', 'done', 'say', 'hold', 'resume', 'listen', 'ignore', 'unlisten'],
52
52
  }, { edge: 'newest', limit: 1 }).items[0]?.index;
53
53
  return Math.max(boundary, last ?? boundary);
54
54
  }
@@ -42,6 +42,7 @@ export interface ListenerCommittedResult {
42
42
  }
43
43
  export declare function listen(square: OperationContext, actor: string, target: string, control?: OperationControl): Promise<ListenerCommittedResult>;
44
44
  export declare function ignore(square: OperationContext, actor: string, target: string, control?: OperationControl): Promise<ListenerCommittedResult>;
45
+ export declare function unlisten(square: OperationContext, actor: string, target: string, control?: OperationControl): Promise<ListenerCommittedResult>;
45
46
  export declare function listening(square: OperationContext, actor: string, control?: OperationControl): Promise<readonly string[]>;
46
47
  export interface LifecycleCommittedResult extends ActivityResult {
47
48
  readonly observation: LandingContext & {
@@ -1,4 +1,4 @@
1
- import { coreDone, coreHold, coreIgnore, coreListen, coreListening, coreResume, decideAct, decideImplicitJoin, decideJoin, resolveKnownName, validateDoneBody } from './decisions.js';
1
+ import { coreDone, coreHold, coreIgnore, coreListen, coreListening, coreResume, coreUnlisten, decideAct, decideImplicitJoin, decideJoin, resolveKnownName, validateDoneBody } from './decisions.js';
2
2
  import { isSquareError, nameKey, SquareError, validateName } from './model.js';
3
3
  import { participantIdentity } from './participant-identity.js';
4
4
  import { deliverPending } from './delivery-operations.js';
@@ -238,7 +238,7 @@ async function landListenerChange(square, verb, actor, target, control) {
238
238
  throwIfAborted(control);
239
239
  const now = square.clock();
240
240
  const committed = (await square.artifact.transact((facts) => {
241
- const act = verb === 'listen' ? coreListen(facts, actor, target, now) : coreIgnore(facts, actor, target, now);
241
+ const act = verb === 'listen' ? coreListen(facts, actor, target, now) : verb === 'ignore' ? coreIgnore(facts, actor, target, now) : coreUnlisten(facts, actor, target, now);
242
242
  const stored = act === undefined ? null : facts.land(act);
243
243
  return { stored, observation: { standingCount: inSquareCount(facts), held: facts.hold().active, now } };
244
244
  }, control?.signal)).value;
@@ -246,6 +246,7 @@ async function landListenerChange(square, verb, actor, target, control) {
246
246
  }
247
247
  export function listen(square, actor, target, control) { return landListenerChange(square, 'listen', actor, target, control); }
248
248
  export function ignore(square, actor, target, control) { return landListenerChange(square, 'ignore', actor, target, control); }
249
+ export function unlisten(square, actor, target, control) { return landListenerChange(square, 'unlisten', actor, target, control); }
249
250
  export async function listening(square, actor, control) { return (await square.artifact.read((facts) => coreListening(facts, actor), control?.signal)).value; }
250
251
  async function landCore(square, verb, actor, body = '', fence = {}, control) {
251
252
  if (verb === 'done')
@@ -8,7 +8,7 @@ import { catchLimit, observeCatchSelection } from './catch-decisions.js';
8
8
  import { directedSuffix } from './activity-feed.js';
9
9
  import { assertLiveOwner } from './participant-host.js';
10
10
  import { buildMemorySquare, openSquare, closeOpenSquare } from './open-square.js';
11
- import { catchUp, done, endOwnedSession, express, hold, ignore, implicitJoin, join, takeover, listen, listening, resume, retireEndedSessionRoutes } from './square-actions.js';
11
+ import { catchUp, done, endOwnedSession, express, hold, ignore, implicitJoin, join, takeover, listen, listening, unlisten, resume, retireEndedSessionRoutes } from './square-actions.js';
12
12
  import { recoveryObservation, currentParticipant, entryPresentation, resolveParticipant, snapshot, queryStatus, queryParticipants, catchPresence, toPublicActivity, publicActivities, selectHistory, NON_READ, HISTORY_DEFAULT_LIMIT, HISTORY_MAX_LIMIT } from './views.js';
13
13
  import { hasAutomaticDeliveryIdentity, hostLedgerForEnv, localParticipantName, localSessionIdentities, presenceEpoch, readParticipantOwner } from './registry.js';
14
14
  import { projectSessionBindings } from './square-projections.js';
@@ -246,7 +246,7 @@ class ParticipantHandle {
246
246
  }
247
247
  async listener(verb, target, options, control) {
248
248
  const name = (await resolveParticipant(this.square, this.name, control)).name;
249
- const { observation: committed, ...result } = await (verb === 'listen' ? listen : ignore)(this.square, name, target, control);
249
+ const { observation: committed, ...result } = await (verb === 'listen' ? listen : verb === 'ignore' ? ignore : unlisten)(this.square, name, target, control);
250
250
  return { ...result, ...(!options.observe ? {} : { observation: {
251
251
  ...observedContext(this.square, committed.standingCount, committed.held, name, committed.now),
252
252
  participant: name, requestedTarget: target, target: result.activity?.target ?? target,
@@ -254,6 +254,7 @@ class ParticipantHandle {
254
254
  }
255
255
  listen(target, options = {}, control) { return this.listener('listen', target, options, control); }
256
256
  ignore(target, options = {}, control) { return this.listener('ignore', target, options, control); }
257
+ unlisten(target, options = {}, control) { return this.listener('unlisten', target, options, control); }
257
258
  async listening(options = {}, control) {
258
259
  const name = (await resolveParticipant(this.square, this.name, control)).name;
259
260
  return { listening: await listening(this.square, name, control),
@@ -52,6 +52,11 @@ export type Act = {
52
52
  actor: Participant;
53
53
  target: Participant;
54
54
  at?: number;
55
+ } | {
56
+ kind: 'unlisten';
57
+ actor: Participant;
58
+ target: Participant;
59
+ at?: number;
55
60
  };
56
61
  export declare function audienceOf(say: {
57
62
  body: string;
@@ -4,6 +4,10 @@ export interface LandingEffect {
4
4
  readonly activity: StoredAct;
5
5
  readonly participant?: ParticipantFact;
6
6
  readonly removeListeningOf?: string;
7
+ readonly removeListening?: {
8
+ readonly listener: string;
9
+ readonly target: string;
10
+ };
7
11
  readonly replaceAttention?: AttentionFact;
8
12
  readonly audience: readonly LandedRecipient[];
9
13
  }
@@ -45,9 +45,13 @@ export function deriveLanding(activity, facts) {
45
45
  replaceAttention = { listener: participant.name, target: activity.target, kind: activity.kind, sinceIndex: activity.index };
46
46
  }
47
47
  }
48
+ const removeListening = activity.kind === 'unlisten'
49
+ && facts.outgoing.some((item) => item.kind === 'listen' && sameName(item.target, activity.target))
50
+ ? { listener: participant.name, target: activity.target } : undefined;
48
51
  return {
49
52
  activity, participant, audience,
50
53
  ...(activity.kind === 'done' ? { removeListeningOf: participant.name } : {}),
54
+ ...(removeListening === undefined ? {} : { removeListening }),
51
55
  ...(replaceAttention === undefined ? {} : { replaceAttention }),
52
56
  };
53
57
  }
@@ -56,7 +60,7 @@ export function landingFacts(read, activity) {
56
60
  actor: activity.actor === undefined ? undefined : read.participant(activity.actor),
57
61
  standing: activity.kind === 'say' ? read.roster().filter((item) => item.joined) : [],
58
62
  incoming: activity.kind === 'say' ? read.incomingAttention(activity.actor) : [],
59
- outgoing: activity.kind === 'listen' || activity.kind === 'ignore' ? read.attention(activity.actor) : [],
63
+ outgoing: activity.kind === 'listen' || activity.kind === 'ignore' || activity.kind === 'unlisten' ? read.attention(activity.actor) : [],
60
64
  replyAuthor: activity.kind === 'say' && activity.reply !== undefined ? read.activity(activity.reply)?.actor : undefined,
61
65
  };
62
66
  }
@@ -7,7 +7,7 @@ export type ArchiveRecord = Readonly<StoredAct>;
7
7
  export interface Activity {
8
8
  readonly id: ActivityId;
9
9
  readonly at: number;
10
- readonly kind: 'join' | 'say' | 'done' | 'hold' | 'resume' | 'listen' | 'ignore';
10
+ readonly kind: 'join' | 'say' | 'done' | 'hold' | 'resume' | 'listen' | 'ignore' | 'unlisten';
11
11
  readonly actor: string;
12
12
  readonly body?: string;
13
13
  readonly mentions: readonly string[];
@@ -184,6 +184,7 @@ export interface Participant {
184
184
  express(body: string, options?: ExpressOptions, control?: OperationControl): Promise<ExpressResult>;
185
185
  listen(target: string, options?: ObservationOptions, control?: OperationControl): Promise<ListenerChangeResult>;
186
186
  ignore(target: string, options?: ObservationOptions, control?: OperationControl): Promise<ListenerChangeResult>;
187
+ unlisten(target: string, options?: ObservationOptions, control?: OperationControl): Promise<ListenerChangeResult>;
187
188
  listening(options?: ObservationOptions, control?: OperationControl): Promise<ListeningResult>;
188
189
  catch(options?: CatchOptions, control?: OperationControl): Promise<CatchResult>;
189
190
  history(query?: HistoryQuery, control?: OperationControl): Promise<HistoryResult>;
@@ -8,17 +8,15 @@ export interface MigrationCounts {
8
8
  readonly routes: number;
9
9
  }
10
10
  export interface MigrationOutcome {
11
- readonly status: 'migrated' | 'already-format-2';
11
+ readonly status: 'migrated' | 'already-current';
12
12
  /** Participants standing after the conversion, for the CLI header. */
13
13
  readonly standing: number;
14
14
  readonly counts?: MigrationCounts;
15
15
  }
16
16
  /**
17
- * Explicit in-place conversion of a normally persisted version 1 artifact.
18
- *
19
- * The legacy snapshot is read, validated and written here and nowhere else in
20
- * production; the row schema, codec and landing transition come from the driver.
21
- * One transaction: preflight framing, the same recheck under the lock, the
22
- * preserved authority, the derived facts, the format flip and a single COMMIT.
17
+ * Explicit in-place conversion of a version 1 snapshot or version 2 row
18
+ * artifact. Each source is validated before and under the write lock; v1 is
19
+ * imported through the normal current row initializer, while v2 rebuilds only
20
+ * activities and its indexes before publishing the new format version.
23
21
  */
24
22
  export declare function migrateSquareFile(location: string, signal?: AbortSignal): Promise<MigrationOutcome>;
@@ -3,7 +3,7 @@ import { inSqliteTransaction } from './sqlite-transaction.js';
3
3
  import { canonicalPath } from './canonical-path.js';
4
4
  import { isWakeRouteKind } from './model.js';
5
5
  import { parseActivityId } from './square-core.js';
6
- import { APPLICATION_ID, V1_SNAPSHOT_TABLE, createSqliteDriver, initializeSqlite, invalidArtifact, validateLegacyFraming, validateSqliteHeader, validateStoredActivity, validateWatchLease, } from './square-sqlite.js';
6
+ import { APPLICATION_ID, ACTIVITIES_TABLE_SQL, ACTIVITY_INDEXES_SQL, CURRENT_USER_VERSION, V2_USER_VERSION, V1_SNAPSHOT_TABLE, createSqliteDriver, initializeSqlite, invalidArtifact, validateLegacyFraming, validateRowFraming, validateSqliteHeader, validateStoredActivity, validateWatchLease, } from './square-sqlite.js';
7
7
  function isObject(value) {
8
8
  return typeof value === 'object' && value !== null && !Array.isArray(value);
9
9
  }
@@ -57,7 +57,7 @@ function validateActs(value) {
57
57
  try {
58
58
  for (const candidate of value) {
59
59
  const act = validateStoredActivity(candidate);
60
- if (act.index <= previous)
60
+ if (act.kind === 'unlisten' || act.index <= previous)
61
61
  return false;
62
62
  previous = act.index;
63
63
  }
@@ -153,58 +153,78 @@ function counts(state) {
153
153
  routes: state.routes?.length ?? 0,
154
154
  };
155
155
  }
156
+ function rowCounts(database) {
157
+ const count = (table) => Number(database.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get().n);
158
+ return { activities: count('activities'), receipts: count('receipts'), leases: count('catch_leases'), routes: count('routes') };
159
+ }
160
+ const ACTIVITY_INDEX_NAMES = ['activity_kind_index', 'activity_actor_index', 'activity_say_actor_index', 'activity_say_time', 'activity_bell_actor_time'];
161
+ function rebuildV2Activities(database) {
162
+ database.exec([
163
+ ...ACTIVITY_INDEX_NAMES.map((name) => `DROP INDEX ${name};`),
164
+ 'ALTER TABLE activities RENAME TO activities_v2;',
165
+ ACTIVITIES_TABLE_SQL,
166
+ 'INSERT INTO activities(act_index,at,kind,actor_json,actor_key_json,reach,reply_index,payload_json) SELECT act_index,at,kind,actor_json,actor_key_json,reach,reply_index,payload_json FROM activities_v2 ORDER BY act_index;',
167
+ 'DROP TABLE activities_v2;',
168
+ ...ACTIVITY_INDEXES_SQL.map((sql) => `${sql};`),
169
+ ].join('\n'));
170
+ }
171
+ function validateMigrationSource(database, location) {
172
+ const before = identity(database);
173
+ if (before.applicationId !== APPLICATION_ID)
174
+ throw invalidArtifact('unrelated SQLite database.');
175
+ if (before.userVersion === 1) {
176
+ validateLegacyFraming(database, APPLICATION_ID, location);
177
+ return 1;
178
+ }
179
+ if (before.userVersion === V2_USER_VERSION) {
180
+ validateRowFraming(database, V2_USER_VERSION, location);
181
+ createSqliteDriver(database).header();
182
+ return 2;
183
+ }
184
+ if (before.userVersion === CURRENT_USER_VERSION) {
185
+ validateSqliteHeader(database, location);
186
+ return undefined;
187
+ }
188
+ throw invalidArtifact('unsupported SQLite format version.');
189
+ }
156
190
  /**
157
- * Explicit in-place conversion of a normally persisted version 1 artifact.
158
- *
159
- * The legacy snapshot is read, validated and written here and nowhere else in
160
- * production; the row schema, codec and landing transition come from the driver.
161
- * One transaction: preflight framing, the same recheck under the lock, the
162
- * preserved authority, the derived facts, the format flip and a single COMMIT.
191
+ * Explicit in-place conversion of a version 1 snapshot or version 2 row
192
+ * artifact. Each source is validated before and under the write lock; v1 is
193
+ * imported through the normal current row initializer, while v2 rebuilds only
194
+ * activities and its indexes before publishing the new format version.
163
195
  */
164
196
  export async function migrateSquareFile(location, signal) {
165
197
  const squarePath = await canonicalPath(location);
166
198
  const database = openExistingSquareDatabase(squarePath);
167
199
  try {
168
- // Owned framing, read-only, before any pragma or DDL touches the artifact.
169
- // Every preflight statement reads one snapshot taken in the helper's
170
- // retryable stage: a transient writer lock is waited out like any other
171
- // acquisition, and a migrator that commits while we look cannot move the
172
- // artifact between the identity read and the schema reads.
173
- let alreadyConverted;
200
+ let alreadyCurrent;
174
201
  const preflight = await inSqliteTransaction(database, false, () => {
175
- const before = identity(database);
176
- if (before.applicationId === APPLICATION_ID && before.userVersion === 1) {
177
- validateLegacyFraming(database, APPLICATION_ID, squarePath);
178
- return;
179
- }
180
- // A row artifact means another migrator finished here; anything else is
181
- // refused by the row header itself.
182
- validateSqliteHeader(database, squarePath);
183
- alreadyConverted = { status: 'already-format-2', standing: standing(database) };
184
- }, () => alreadyConverted, signal);
185
- if (preflight !== undefined)
186
- return preflight;
202
+ const source = validateMigrationSource(database, squarePath);
203
+ if (source === undefined)
204
+ alreadyCurrent = { status: 'already-current', standing: standing(database) };
205
+ return source;
206
+ }, (source) => source, signal);
207
+ if (preflight === undefined)
208
+ return alreadyCurrent;
187
209
  database.exec('PRAGMA busy_timeout = 0; PRAGMA synchronous = FULL');
188
210
  const result = {};
189
211
  await inSqliteTransaction(database, true, () => {
190
- // Another migration may have finished between preflight and this lock.
191
- const locked = identity(database);
192
- if (locked.applicationId !== APPLICATION_ID || locked.userVersion !== 1) {
193
- // A valid row artifact is done already; anything else is refused here.
194
- validateSqliteHeader(database, squarePath);
195
- return;
196
- }
197
- validateLegacyFraming(database, APPLICATION_ID, squarePath);
198
- result.converted = true;
212
+ const source = validateMigrationSource(database, squarePath);
213
+ result.source = source;
199
214
  }, () => {
200
- if (result.converted !== true)
201
- return;
202
- const legacy = decodeLegacySnapshot(database);
203
- initializeSqlite(database, legacy.state, legacy.revision);
204
- database.exec(`DROP TABLE ${V1_SNAPSHOT_TABLE}; PRAGMA user_version = 2`);
205
- result.outcome = { status: 'migrated', standing: standing(database), counts: counts(legacy.state) };
215
+ if (result.source === 1) {
216
+ const legacy = decodeLegacySnapshot(database);
217
+ initializeSqlite(database, legacy.state, legacy.revision);
218
+ database.exec(`DROP TABLE ${V1_SNAPSHOT_TABLE}; PRAGMA user_version = ${CURRENT_USER_VERSION}`);
219
+ result.outcome = { status: 'migrated', standing: standing(database), counts: counts(legacy.state) };
220
+ }
221
+ else if (result.source === 2) {
222
+ rebuildV2Activities(database);
223
+ database.exec(`PRAGMA user_version = ${CURRENT_USER_VERSION}`);
224
+ result.outcome = { status: 'migrated', standing: standing(database), counts: rowCounts(database) };
225
+ }
206
226
  }, signal);
207
- return result.outcome ?? { status: 'already-format-2', standing: standing(database) };
227
+ return result.outcome ?? { status: 'already-current', standing: standing(database) };
208
228
  }
209
229
  finally {
210
230
  database.close();
@@ -19,6 +19,8 @@ export interface SquareState {
19
19
  }
20
20
  export declare function emptyRuntimeState(nextActIndex?: number): SquareRuntimeState;
21
21
  export declare const APPLICATION_ID = 1397838405;
22
+ export declare const V2_USER_VERSION = 2;
23
+ export declare const CURRENT_USER_VERSION = 3;
22
24
  export declare const V1_SNAPSHOT_TABLE = "square_snapshot";
23
25
  export interface LegacyFraming {
24
26
  readonly applicationId: number;
@@ -32,11 +34,15 @@ export interface LegacyFraming {
32
34
  * migration. Anything else is unrelated or unsupported, not repaired.
33
35
  */
34
36
  export declare function validateLegacyFraming(database: DatabaseSync, applicationId: number, location?: string): LegacyFraming;
35
- export declare const SCHEMA_SQL = "CREATE TABLE square_meta (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n revision INTEGER NOT NULL CHECK (revision BETWEEN 0 AND 9007199254740991),\n next_act_index INTEGER NOT NULL CHECK (next_act_index BETWEEN 0 AND 9007199254740991),\n hard_cap INTEGER CHECK (hard_cap BETWEEN 1 AND 9007199254740991),\n throttle_per_minute INTEGER CHECK (throttle_per_minute BETWEEN 1 AND 9007199254740991),\n preamble_json TEXT NOT NULL CHECK (json_valid(preamble_json)),\n warmup_json TEXT NOT NULL CHECK (json_valid(warmup_json))\n);\nCREATE TABLE activities (\n act_index INTEGER PRIMARY KEY CHECK (act_index BETWEEN 0 AND 9007199254740991),\n at REAL NOT NULL,\n kind TEXT NOT NULL CHECK (kind IN ('join','done','say','hold','resume','read','listen','ignore')),\n actor_json TEXT COLLATE BINARY,\n actor_key_json TEXT COLLATE BINARY,\n reach TEXT CHECK (reach = 'bell'),\n reply_index INTEGER CHECK (reply_index >= 0 AND reply_index < act_index),\n payload_json TEXT NOT NULL CHECK (json_valid(payload_json)),\n CHECK ((actor_json IS NULL) = (actor_key_json IS NULL)),\n CHECK (kind IN ('hold','resume') OR actor_json IS NOT NULL),\n CHECK (kind = 'say' OR (reach IS NULL AND reply_index IS NULL))\n);\nCREATE INDEX activity_kind_index ON activities(kind, act_index);\nCREATE INDEX activity_actor_index ON activities(actor_key_json, act_index);\nCREATE INDEX activity_say_actor_index ON activities(actor_key_json, act_index) WHERE kind='say';\nCREATE INDEX activity_say_time ON activities(at, act_index) WHERE kind='say';\nCREATE INDEX activity_bell_actor_time ON activities(actor_key_json, at, act_index) WHERE kind='say' AND reach='bell';\n\nCREATE TABLE receipts (\n owner_json TEXT COLLATE BINARY NOT NULL,\n act_index INTEGER NOT NULL CHECK (act_index BETWEEN 0 AND 9007199254740991),\n at REAL NOT NULL,\n PRIMARY KEY (owner_json, act_index)\n) WITHOUT ROWID;\nCREATE INDEX receipt_owner_time ON receipts(owner_json, at);\nCREATE TABLE catch_leases (\n owner_json TEXT COLLATE BINARY PRIMARY KEY NOT NULL,\n lease_id_json TEXT NOT NULL,\n heartbeat_at REAL NOT NULL,\n expires_at REAL NOT NULL CHECK (expires_at >= heartbeat_at),\n filter_json TEXT CHECK (filter_json IS NULL OR json_valid(filter_json))\n) WITHOUT ROWID;\n\nCREATE TABLE routes (\n position INTEGER PRIMARY KEY CHECK (position >= 0),\n participant_key_json TEXT COLLATE BINARY NOT NULL,\n session_json TEXT COLLATE BINARY NOT NULL,\n kind TEXT NOT NULL CHECK (kind IN ('opencode-server','codex-queue','claude-native','paseo')),\n updated_at REAL NOT NULL,\n payload_json TEXT NOT NULL CHECK (json_valid(payload_json))\n);\nCREATE INDEX route_participant ON routes(participant_key_json, position);\nCREATE INDEX route_session ON routes(session_json, position);\n\nCREATE TABLE participants (\n name_key_json TEXT COLLATE BINARY PRIMARY KEY NOT NULL,\n name_json TEXT NOT NULL,\n first_index INTEGER NOT NULL UNIQUE,\n joined INTEGER NOT NULL CHECK (joined IN (0,1)),\n done INTEGER NOT NULL CHECK (done IN (0,1)),\n say_count INTEGER NOT NULL CHECK (say_count >= 0),\n last_active_at REAL,\n last_join_index INTEGER,\n CHECK (NOT (joined=1 AND done=1))\n) WITHOUT ROWID;\nCREATE TABLE attention (\n listener_key_json TEXT COLLATE BINARY NOT NULL,\n target_key_json TEXT COLLATE BINARY NOT NULL,\n target_json TEXT NOT NULL,\n kind TEXT NOT NULL CHECK (kind IN ('listen','ignore')),\n since_index INTEGER NOT NULL,\n PRIMARY KEY (listener_key_json, target_key_json)\n) WITHOUT ROWID;\nCREATE INDEX attention_target ON attention(target_key_json, kind, listener_key_json);\nCREATE TABLE landed_audience (\n act_index INTEGER NOT NULL,\n recipient_key_json TEXT COLLATE BINARY NOT NULL,\n recipient_order INTEGER NOT NULL CHECK (recipient_order >= 0),\n route TEXT NOT NULL CHECK (route IN ('mention','reply','attention','bell')),\n PRIMARY KEY (act_index, recipient_key_json),\n UNIQUE (act_index, recipient_order)\n) WITHOUT ROWID;\nCREATE INDEX audience_recipient ON landed_audience(recipient_key_json, act_index);";
37
+ export declare const ACTIVITIES_TABLE_SQL = "CREATE TABLE activities (\n act_index INTEGER PRIMARY KEY CHECK (act_index BETWEEN 0 AND 9007199254740991),\n at REAL NOT NULL,\n kind TEXT NOT NULL CHECK (kind IN ('join','done','say','hold','resume','read','listen','ignore','unlisten')),\n actor_json TEXT COLLATE BINARY,\n actor_key_json TEXT COLLATE BINARY,\n reach TEXT CHECK (reach = 'bell'),\n reply_index INTEGER CHECK (reply_index >= 0 AND reply_index < act_index),\n payload_json TEXT NOT NULL CHECK (json_valid(payload_json)),\n CHECK ((actor_json IS NULL) = (actor_key_json IS NULL)),\n CHECK (kind IN ('hold','resume') OR actor_json IS NOT NULL),\n CHECK (kind = 'say' OR (reach IS NULL AND reply_index IS NULL))\n);";
38
+ export declare const ACTIVITY_INDEXES_SQL: readonly ["CREATE INDEX activity_kind_index ON activities(kind, act_index)", "CREATE INDEX activity_actor_index ON activities(actor_key_json, act_index)", "CREATE INDEX activity_say_actor_index ON activities(actor_key_json, act_index) WHERE kind='say'", "CREATE INDEX activity_say_time ON activities(at, act_index) WHERE kind='say'", "CREATE INDEX activity_bell_actor_time ON activities(actor_key_json, at, act_index) WHERE kind='say' AND reach='bell'"];
39
+ export declare const SCHEMA_SQL: string;
40
+ export declare const SCHEMA_SQL_V2: string;
36
41
  export declare function invalidArtifact(detail: string): SquareError;
37
42
  export declare function validateStoredActivity(value: unknown): StoredAct;
38
43
  export declare function validateWatchLease(value: unknown): WatchLease;
39
44
  export declare function validateRoute(value: unknown): RouteFact;
45
+ export declare function validateRowFraming(db: DatabaseSync, version: 2 | 3, location?: string): void;
40
46
  export declare function validateSqliteHeader(db: DatabaseSync, location?: string): SquareHeader;
41
47
  export interface SqliteDriver extends SquareRead, LandingWriter {
42
48
  readonly changed: boolean;
@@ -6,6 +6,8 @@ export function emptyRuntimeState(nextActIndex = 0) {
6
6
  }
7
7
  export const APPLICATION_ID = 0x53515245;
8
8
  const V1_USER_VERSION = 1;
9
+ export const V2_USER_VERSION = 2;
10
+ export const CURRENT_USER_VERSION = 3;
9
11
  export const V1_SNAPSHOT_TABLE = 'square_snapshot';
10
12
  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
13
  const V1_SNAPSHOT_COLUMNS = [
@@ -50,19 +52,12 @@ export function validateLegacyFraming(database, applicationId, location = '') {
50
52
  function normalizedLegacySql(sql) {
51
53
  return sql.trim().replace(/\s+/g, ' ').replace(/\s*(>=|[(),=])\s*/g, '$1');
52
54
  }
53
- export const SCHEMA_SQL = `CREATE TABLE square_meta (
54
- id INTEGER PRIMARY KEY CHECK (id = 1),
55
- revision INTEGER NOT NULL CHECK (revision BETWEEN 0 AND 9007199254740991),
56
- next_act_index INTEGER NOT NULL CHECK (next_act_index BETWEEN 0 AND 9007199254740991),
57
- hard_cap INTEGER CHECK (hard_cap BETWEEN 1 AND 9007199254740991),
58
- throttle_per_minute INTEGER CHECK (throttle_per_minute BETWEEN 1 AND 9007199254740991),
59
- preamble_json TEXT NOT NULL CHECK (json_valid(preamble_json)),
60
- warmup_json TEXT NOT NULL CHECK (json_valid(warmup_json))
61
- );
62
- CREATE TABLE activities (
55
+ const V2_ACTIVITY_KIND_CHECK = "kind IN ('join','done','say','hold','resume','read','listen','ignore')";
56
+ const CURRENT_ACTIVITY_KIND_CHECK = "kind IN ('join','done','say','hold','resume','read','listen','ignore','unlisten')";
57
+ export const ACTIVITIES_TABLE_SQL = `CREATE TABLE activities (
63
58
  act_index INTEGER PRIMARY KEY CHECK (act_index BETWEEN 0 AND 9007199254740991),
64
59
  at REAL NOT NULL,
65
- kind TEXT NOT NULL CHECK (kind IN ('join','done','say','hold','resume','read','listen','ignore')),
60
+ kind TEXT NOT NULL CHECK (${CURRENT_ACTIVITY_KIND_CHECK}),
66
61
  actor_json TEXT COLLATE BINARY,
67
62
  actor_key_json TEXT COLLATE BINARY,
68
63
  reach TEXT CHECK (reach = 'bell'),
@@ -71,12 +66,25 @@ CREATE TABLE activities (
71
66
  CHECK ((actor_json IS NULL) = (actor_key_json IS NULL)),
72
67
  CHECK (kind IN ('hold','resume') OR actor_json IS NOT NULL),
73
68
  CHECK (kind = 'say' OR (reach IS NULL AND reply_index IS NULL))
69
+ );`;
70
+ export const ACTIVITY_INDEXES_SQL = [
71
+ 'CREATE INDEX activity_kind_index ON activities(kind, act_index)',
72
+ 'CREATE INDEX activity_actor_index ON activities(actor_key_json, act_index)',
73
+ "CREATE INDEX activity_say_actor_index ON activities(actor_key_json, act_index) WHERE kind='say'",
74
+ "CREATE INDEX activity_say_time ON activities(at, act_index) WHERE kind='say'",
75
+ "CREATE INDEX activity_bell_actor_time ON activities(actor_key_json, at, act_index) WHERE kind='say' AND reach='bell'",
76
+ ];
77
+ export const SCHEMA_SQL = `CREATE TABLE square_meta (
78
+ id INTEGER PRIMARY KEY CHECK (id = 1),
79
+ revision INTEGER NOT NULL CHECK (revision BETWEEN 0 AND 9007199254740991),
80
+ next_act_index INTEGER NOT NULL CHECK (next_act_index BETWEEN 0 AND 9007199254740991),
81
+ hard_cap INTEGER CHECK (hard_cap BETWEEN 1 AND 9007199254740991),
82
+ throttle_per_minute INTEGER CHECK (throttle_per_minute BETWEEN 1 AND 9007199254740991),
83
+ preamble_json TEXT NOT NULL CHECK (json_valid(preamble_json)),
84
+ warmup_json TEXT NOT NULL CHECK (json_valid(warmup_json))
74
85
  );
75
- CREATE INDEX activity_kind_index ON activities(kind, act_index);
76
- CREATE INDEX activity_actor_index ON activities(actor_key_json, act_index);
77
- CREATE INDEX activity_say_actor_index ON activities(actor_key_json, act_index) WHERE kind='say';
78
- CREATE INDEX activity_say_time ON activities(at, act_index) WHERE kind='say';
79
- CREATE INDEX activity_bell_actor_time ON activities(actor_key_json, at, act_index) WHERE kind='say' AND reach='bell';
86
+ ${ACTIVITIES_TABLE_SQL}
87
+ ${ACTIVITY_INDEXES_SQL.join(';\n')};
80
88
 
81
89
  CREATE TABLE receipts (
82
90
  owner_json TEXT COLLATE BINARY NOT NULL,
@@ -134,6 +142,8 @@ CREATE TABLE landed_audience (
134
142
  ) WITHOUT ROWID;
135
143
  CREATE INDEX audience_recipient ON landed_audience(recipient_key_json, act_index);`;
136
144
  const EXPECTED_SCHEMA_SQL = SCHEMA_SQL.split(';').map(s => s.trim()).filter(Boolean).map(normalizedSql).sort();
145
+ export const SCHEMA_SQL_V2 = SCHEMA_SQL.replace(CURRENT_ACTIVITY_KIND_CHECK, V2_ACTIVITY_KIND_CHECK);
146
+ const EXPECTED_SCHEMA_SQL_V2 = SCHEMA_SQL_V2.split(';').map(s => s.trim()).filter(Boolean).map(normalizedSql).sort();
137
147
  const encode = JSON.stringify;
138
148
  const key = (name) => encode(nameKey(name));
139
149
  export function invalidArtifact(detail) {
@@ -189,7 +199,7 @@ function immutable(value) {
189
199
  }
190
200
  return value;
191
201
  }
192
- const kinds = ['join', 'done', 'say', 'hold', 'resume', 'read', 'listen', 'ignore'];
202
+ const kinds = ['join', 'done', 'say', 'hold', 'resume', 'read', 'listen', 'ignore', 'unlisten'];
193
203
  function head(row) {
194
204
  const index = integer(row.act_index), at = finite(row.at);
195
205
  requireFact(kinds.includes(string(row.kind)));
@@ -237,6 +247,7 @@ export function validateStoredActivity(value) {
237
247
  break;
238
248
  case 'listen':
239
249
  case 'ignore':
250
+ case 'unlisten':
240
251
  shape(a, [...common, 'target']);
241
252
  string(a.target, true);
242
253
  break;
@@ -247,7 +258,7 @@ export function validateStoredActivity(value) {
247
258
  function activity(row) {
248
259
  const h = head(row), payload = object(json(row.payload_json));
249
260
  // Payload cannot shadow indexed structural columns.
250
- 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'] : [];
261
+ const allowed = h.kind === 'say' ? ['body', 'mentions'] : h.kind === 'done' || h.kind === 'hold' ? ['body'] : h.kind === 'read' ? ['through'] : h.kind === 'listen' || h.kind === 'ignore' || h.kind === 'unlisten' ? ['target'] : [];
251
262
  requireFact(Object.keys(payload).every(k => allowed.includes(k)));
252
263
  return immutable(validateStoredActivity({ ...h, ...payload }));
253
264
  }
@@ -315,6 +326,17 @@ function route(row) {
315
326
  }));
316
327
  }
317
328
  function normalizedSql(sql) { return sql.trim().replace(/;$/, '').replace(/\s+/g, ' ').replace(/\s*([(),=])\s*/g, '$1'); }
329
+ function validateOwnedSchema(db, expected, location) {
330
+ 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();
331
+ requireFact(actual.length === expected.length && actual.every((sql, i) => sql === expected[i]), 'unsupported row schema.');
332
+ requireFact(db.prepare('PRAGMA journal_mode').get().journal_mode === (location === ':memory:' ? 'memory' : 'delete'), 'unsupported journal mode.');
333
+ }
334
+ export function validateRowFraming(db, version, location = '') {
335
+ const identity = db.prepare('SELECT application_id,user_version FROM pragma_application_id,pragma_user_version').get();
336
+ requireFact(identity.application_id === APPLICATION_ID, 'unrelated SQLite database.');
337
+ requireFact(identity.user_version === version, 'unsupported SQLite format version.');
338
+ validateOwnedSchema(db, version === V2_USER_VERSION ? EXPECTED_SCHEMA_SQL_V2 : EXPECTED_SCHEMA_SQL, location);
339
+ }
318
340
  export function validateSqliteHeader(db, location = '') {
319
341
  const identity = db.prepare('SELECT application_id,user_version FROM pragma_application_id,pragma_user_version').get();
320
342
  requireFact(identity.application_id === APPLICATION_ID, 'unrelated SQLite database.');
@@ -322,10 +344,12 @@ export function validateSqliteHeader(db, location = '') {
322
344
  validateLegacyFraming(db, APPLICATION_ID, location);
323
345
  throw new SquareError('invalid_args', `Square artifact at ${location} needs migration.\nStop sessions using it, back it up, then migrate and verify before restarting.`, { reason: 'migration_required' });
324
346
  }
325
- requireFact(identity.user_version === 2, 'unsupported SQLite format version.');
326
- 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();
327
- requireFact(actual.length === EXPECTED_SCHEMA_SQL.length && actual.every((sql, i) => sql === EXPECTED_SCHEMA_SQL[i]), 'unsupported row schema.');
328
- requireFact(db.prepare('PRAGMA journal_mode').get().journal_mode === (location === ':memory:' ? 'memory' : 'delete'), 'unsupported journal mode.');
347
+ if (identity.user_version === V2_USER_VERSION) {
348
+ validateRowFraming(db, V2_USER_VERSION, location);
349
+ throw new SquareError('invalid_args', `Square artifact at ${location} needs migration.\nStop sessions using it, back it up, then migrate and verify before restarting.`, { reason: 'migration_required' });
350
+ }
351
+ requireFact(identity.user_version === CURRENT_USER_VERSION, 'unsupported SQLite format version.');
352
+ validateRowFraming(db, CURRENT_USER_VERSION, location);
329
353
  return createSqliteDriver(db).header();
330
354
  }
331
355
  function activitySelection(filter, alias = 'a') {
@@ -550,6 +574,12 @@ export function createSqliteDriver(db) {
550
574
  }
551
575
  if (effect.removeListeningOf !== undefined)
552
576
  run("DELETE FROM attention WHERE listener_key_json=? AND kind='listen'", [key(effect.removeListeningOf)]);
577
+ if (effect.removeListening !== undefined) {
578
+ const target = effect.removeListening;
579
+ string(target.listener, true);
580
+ string(target.target, true);
581
+ run("DELETE FROM attention WHERE listener_key_json=? AND target_key_json=? AND kind='listen'", [key(target.listener), key(target.target)]);
582
+ }
553
583
  if (effect.replaceAttention) {
554
584
  const t = effect.replaceAttention;
555
585
  string(t.listener, true);
@@ -34,8 +34,9 @@ export const SQUARE_TOOLS = [
34
34
  limit: z.number().int().min(1).optional(), order: z.enum(['asc', 'desc']).optional(), before: activityId.optional(), after: activityId.optional(),
35
35
  grep: z.string().optional(), fixed: z.string().optional(), from: stringArray.optional(), mention: z.string().optional(),
36
36
  }, (square, args, control) => square.history(args, control)),
37
- tool('listen', 'Listen for future bare activity from a participant, clearing any ignore of them.', { as: participant, target: z.string() }, async (square, { as, target }, control) => (await square.participant(as)).listen(target, {}, control)),
37
+ tool('listen', 'Listen for future activity from a participant, clearing any ignore of them.', { as: participant, target: z.string() }, async (square, { as, target }, control) => (await square.participant(as)).listen(target, {}, control)),
38
38
  tool('ignore', 'Block a participant’s future mentions and bare activity. Listening again clears the ignore; already landed activity is unchanged.', { as: participant, target: z.string() }, async (square, { as, target }, control) => (await square.participant(as)).ignore(target, {}, control)),
39
+ tool('unlisten', 'Stop listening to a participant’s future activity; mentions and replies still reach you. An existing ignore is unchanged.', { as: participant, target: z.string() }, async (square, { as, target }, control) => (await square.participant(as)).unlisten(target, {}, control)),
39
40
  tool('listening', 'List participants this participant is listening to.', { as: participant }, async (square, { as }, control) => (await square.participant(as)).listening({}, control)),
40
41
  tool('hold', 'Raise a hand to hold the square.', { as: participant, reason: z.string().optional() }, async (square, { as, reason }, control) => (await square.participant(as)).hold(reason, {}, control)),
41
42
  tool('resume', 'Release the current hold.', { as: participant }, async (square, { as }, control) => (await square.participant(as)).resume({}, control)),
package/dist/views.d.ts CHANGED
@@ -6,7 +6,7 @@ import type { Activity, ArchiveRecord, HistoryQuery, HistoryContinuation, Operat
6
6
  export type { EntryPresentation, ListPresentation, CatchPresence, StreamProjection } from './square-facade.js';
7
7
  export declare const HISTORY_DEFAULT_LIMIT = 10;
8
8
  export declare const HISTORY_MAX_LIMIT = 100;
9
- export declare const NON_READ: readonly ["join", "done", "say", "hold", "resume", "listen", "ignore"];
9
+ export declare const NON_READ: readonly ["join", "done", "say", "hold", "resume", "listen", "ignore", "unlisten"];
10
10
  /** The one StoredAct-to-public-Activity field mapping. Rejects read and actorless acts. */
11
11
  export declare function toPublicActivity(stored: StoredAct): Activity;
12
12
  export declare function parseRequiredActivityId(id: ActivityId): number;
package/dist/views.js CHANGED
@@ -9,7 +9,7 @@ export const HISTORY_DEFAULT_LIMIT = 10;
9
9
  export const HISTORY_MAX_LIMIT = 100;
10
10
  const STATUS_HERE_WINDOW_MS = 30 * 60_000;
11
11
  const PRESENCE_WINDOW_MS = 8 * 60 * 60_000;
12
- export const NON_READ = ['join', 'done', 'say', 'hold', 'resume', 'listen', 'ignore'];
12
+ export const NON_READ = ['join', 'done', 'say', 'hold', 'resume', 'listen', 'ignore', 'unlisten'];
13
13
  const ARCHIVE = new Set(['join', 'done', 'say', 'hold', 'resume']);
14
14
  /** The one StoredAct-to-public-Activity field mapping. Rejects read and actorless acts. */
15
15
  export function toPublicActivity(stored) {
@@ -1,78 +1,75 @@
1
- # Migrating an existing square to row storage
1
+ # Migrating an existing square
2
2
 
3
3
  Square stores authority in one versioned SQLite artifact. Version 1 kept the
4
- whole history as one JSON snapshot; version 2 keeps typed rows — activities,
5
- sparse receipts, catch leases, ordered routes and the small configuration
6
- facts — in one in-place database.
4
+ whole history as one JSON snapshot. Version 2 introduced typed rows. Current
5
+ format 3 adds the `unlisten` history event without changing the other tables.
7
6
 
8
- Existing artifacts are **not** converted automatically. A normal read of a
9
- version 1 artifact refuses with a descriptive offline-migration requirement.
10
- The CLI adds the concrete recovery command; native tools retain the description:
7
+ Existing artifacts are **not** converted automatically. Ordinary reads and
8
+ writes refuse versions 1 and 2 with an offline-migration requirement. The CLI
9
+ adds the concrete recovery command; native tools retain the description:
11
10
 
12
11
  ```sh
13
12
  square --location /path/to/SQUARE.square migrate
14
13
  ```
15
14
 
16
- That is the only migration command. There is no `--check`, `--backup`,
17
- `--force`, bulk mode, sidecar, daemon or dual reader, and no operator
18
- configuration to set.
15
+ This is the only artifact migration command. There is no `--check`, `--backup`,
16
+ `--force`, bulk mode, sidecar, daemon or dual reader. The host ledger is a
17
+ separate resource; unlisten does not change its format.
19
18
 
20
19
  ## What the command does
21
20
 
22
- It converts the artifact **in place**, in **one transaction**:
23
-
24
- 1. Reads the owned legacy framing before touching anything (application id,
25
- format version, `DELETE` journal mode, the exact snapshot schema) and
26
- refuses anything that is not an owned version 1 square.
27
- 2. Takes `BEGIN IMMEDIATE`, re-checks that framing under the lock, and decodes
28
- and validates the complete snapshot inside the lock.
29
- 3. Creates the row schema and writes the preserved authority: activity indices
30
- and their gaps, optional-field presence, raw receipt and lease owner keys,
31
- route order and duplicates, string content and finite fractional timestamps.
32
- 4. Rebuilds the derived participant, hold, attention and landed-audience facts
33
- through the same single landing transition the runtime uses.
34
- 5. Drops the snapshot table, sets the format version, and commits once.
35
-
36
- The revision is preserved; migration never invents or advances it. Nothing is
37
- published piecemeal, and the file keeps its path, inode and registry identity.
38
-
39
- Two shapes are already fine and change nothing:
40
-
41
- - an artifact that is already version 2 (reported as already format 2);
42
- - an artifact converted by another process while this one waited for the lock.
21
+ Both supported conversions happen **in place**, in **one transaction**:
22
+
23
+ 1. Read the owned framing before any persistent pragma or DDL: application id,
24
+ source format, `DELETE` journal mode, and exact known schema.
25
+ 2. Take `BEGIN IMMEDIATE` and re-check that framing under the lock.
26
+ 3. Convert the source using its explicit path:
27
+ - **Version 1:** decode and validate the complete snapshot, create current
28
+ rows, and preserve activity indices/gaps, optional fields, raw receipt and
29
+ lease owners, ordered routes, strings and finite fractional timestamps.
30
+ Rebuild derived participant, hold, attention and landed-audience facts
31
+ with the runtime's shared landing transition, then drop the snapshot.
32
+ - **Version 2:** rebuild only `activities` and its indexes to accept
33
+ `unlisten`, copying every existing activity row unchanged. All other
34
+ tables, including attention, landed audience, receipts, leases, routes
35
+ and configuration, remain untouched. No history is reinterpreted.
36
+ 4. Set format 3 and commit once.
37
+
38
+ Revision and assigned activity indices are preserved. Migration adds no domain
39
+ activity. Nothing is published piecemeal, and path, inode and registry identity
40
+ remain unchanged. A format-3 artifact is reported as already current without
41
+ writing, including when another migrator finished while this one waited.
43
42
 
44
43
  ## What it refuses
45
44
 
46
- Nothing is written when the input is not a normally persisted version 1 square:
47
- a missing file, an unrelated SQLite database, an unsupported format version, a
48
- different journal mode, an unknown snapshot schema, unreadable snapshot JSON, or
49
- a route carrying a non-finite number (an exponent-overflow value such as
50
- `1e999`, which the previous permissive check accepted and an ordinary JSON round
51
- trip cannot carry) are all refused before any write. The refusal names the
52
- offending route property.
53
-
54
- Journal mode is never rewritten to make a migration possible.
45
+ Missing files, unrelated databases, unsupported versions, different journal
46
+ modes, unknown schemas and malformed headers are refused without a write.
47
+ Version-1 snapshot JSON and activity vocabulary must be valid for that source
48
+ format. Non-finite numbers anywhere in a legacy route, including nested extra
49
+ properties, are refused with the offending property named; they are not
50
+ silently coerced to null. Journal mode is never rewritten to make conversion
51
+ possible, and migration is not a corruption-repair command.
55
52
 
56
53
  ## Before you migrate
57
54
 
58
- - Stop attached runtimes, or upgrade them together with the migration. An
59
- in-flight remote delivery is not revoked by migration, and a version 1 writer
60
- either commits before the conversion or is refused afterwards — it can never
61
- overwrite converted rows.
62
- - An offline backup is optional and, if you want one, ordinary: copy the file
63
- while no writer is attached. The command does not manage backups.
55
+ Stop every session and service using that artifact. Make an offline backup, or
56
+ use SQLite's backup facilities rather than copying a live database. The command
57
+ does not manage backups, stop processes, revoke in-flight remote deliveries,
58
+ or update installed runtimes.
64
59
 
65
- ## Crash safety
60
+ Upgrade all attached runtimes before reopening the migrated artifact. Both
61
+ version-1 and version-2 runtimes refuse format 3; do not mix old and new
62
+ processes against the converted file. An older writer either commits before
63
+ conversion or meets the new version gate afterwards.
66
64
 
67
- Before `COMMIT`, SQLite's own journal recovers the complete version 1 artifact;
68
- after it, the complete version 2 rows are there. A process killed mid-migration
69
- therefore leaves one or the other, never a partial artifact, and re-running the
70
- command finishes the job. A busy `COMMIT` retries the same transaction and never
71
- re-runs the conversion.
65
+ ## Crash safety and checking
72
66
 
73
- ## Checking an artifact
67
+ Before `COMMIT`, SQLite's journal recovers the complete source version; after
68
+ it, complete format-3 rows are present. A failure or process death cannot leave
69
+ a partial conversion. A busy `COMMIT` retries the same transaction, never the
70
+ copy or domain callback.
74
71
 
75
- `square --location <path> doctor` is read-only. On a legacy artifact it
76
- recognizes version 1 and prints the migration command; it does not call the
77
- artifact damaged. On version 2 it validates the rows and recomputes the derived
78
- indices to compare them against the stored ones.
72
+ `square --location <path> doctor` is read-only. On versions 1 and 2 it prints
73
+ the migration requirement without calling the artifact damaged. On current
74
+ format 3 it validates rows and recomputes derived indices for comparison.
75
+ Run it after migration and before restarting upgraded clients.
@@ -1,6 +1,9 @@
1
1
  # Row-oriented artifact design
2
2
 
3
- **Status: design closed; row runtime candidate implemented and measured, awaiting independent review/acceptance.**
3
+ **Historical accepted design for artifact format 2.** The current runtime uses format 3 for the
4
+ `unlisten` event; see [Square API](square-api.md#artifact-format-and-explicit-migration) for current
5
+ format/migration behavior. The schema and delivery-stage terms below record the original row-storage
6
+ replacement, not a pending implementation or a second current-format definition.
4
7
  Source baseline: `99679eb` (0.3.70). This document specifies the design; [runtime delivery evidence](row-runtime-delivery-evidence.md) records implementation verification and actual capacity measurements. Explicit migration remains a separately accepted delivery.
5
8
 
6
9
  ## Decision in brief
@@ -42,7 +42,7 @@ try {
42
42
  ## One set of operations and defaults
43
43
 
44
44
  Square owns `history`, `status`, `participants`, `snapshot`, `stream`, and recovery observations.
45
- Participant owns `express`, `catch`, `listen`, `ignore`, `listening`, `hold`, `resume`, `done`;
45
+ Participant owns `express`, `catch`, `listen`, `unlisten`, `ignore`, `listening`, `hold`, `resume`, `done`;
46
46
  its `history` delegates to the same Square history operation.
47
47
 
48
48
  - Express waits while held/throttled unless `noWait: true`, for **every** caller. `ExpressResult`
@@ -58,6 +58,10 @@ its `history` delegates to the same Square history operation.
58
58
  bounds it. CLI/tool schemas accept numeric limits only. History never writes receipts.
59
59
  - `participants()` returns `{participants, observation?}` and `listening()` returns
60
60
  `{listening, observation?}`, not arrays carrying metadata.
61
+ - `unlisten(target)` removes only that participant's current listen edge to the target, without
62
+ leaving or blocking future mentions/replies. It returns the same `ListenerChangeResult` as listen
63
+ and ignore, with `activity: null` when no listen exists. Existing ignores are unchanged. Earlier
64
+ landed audiences and receipts are never rewritten; listening again remains available.
61
65
  - Lifecycle operations keep contextual reentry/ownership policy. In particular, done retains its
62
66
  native owner epoch fence instead of blindly joining first. Done accepts no message.
63
67
 
@@ -95,6 +99,23 @@ resources. Express wire output omits the internal delivery diagnostic as before.
95
99
  They are not methods on a synthetic cross-square object. Diagnose/migrate resolve a path without
96
100
  opening a valid current-format Square first.
97
101
 
102
+ ## Artifact format and explicit migration
103
+
104
+ The current artifact format is 3. It records `unlisten` as a real history activity; the derived
105
+ attention table still contains only listen or ignore edges. New artifacts use format 3. Ordinary
106
+ opens refuse older formats with migration guidance, and never migrate implicitly.
107
+
108
+ Stop all sessions and services using a particular artifact, back it up offline (or use SQLite's
109
+ backup API), then run `square --location <path.square> migrate` and `doctor` before reopening it
110
+ with upgraded runtimes. Older runtimes refuse format 3; do not mix versions against a migrated
111
+ artifact. Updating a package does not migrate any file.
112
+
113
+ Format 2 migration changes only the activities table's accepted event kinds and the format marker,
114
+ inside one transaction. Existing rows, revision, assigned indices, attention, landed audiences,
115
+ receipts, leases, and routes remain unchanged. Format 1 snapshots import directly into the current
116
+ format. Repeated migration of a current artifact is a byte-preserving no-op. This is separate from
117
+ host-ledger migration, whose format does not change for unlisten.
118
+
98
119
  ## Deliberate migration breaks
99
120
 
100
121
  The independent `createSquareApplication` API, its options/results/aliases, and `square-application.ts`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/square",
3
- "version": "0.3.80",
3
+ "version": "0.3.81",
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": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "square",
3
- "version": "0.3.80",
3
+ "version": "0.3.81",
4
4
  "description": "Native Claude Code inbox delivery for Square participants",
5
5
  "types": "./types.d.ts",
6
6
  "author": {
@@ -54,9 +54,9 @@ Addressing is not a secrecy boundary. Everything stays readable through `history
54
54
 
55
55
  Waiting with an idle `catch` is the normal way to stay present between expressions. Joining tells you exactly what to keep open. Do not build a polling loop.
56
56
 
57
- ## Listen and ignore
57
+ ## Listen, unlisten and ignore
58
58
 
59
- `listen` to a participant so their future bare activities reach you. Mentions reach you regardless; bells reach everyone. `ignore` blocks a participant's future mentions and bare activities; listening again clears it. Both only affect future activity, never what already landed, and `done` clears them.
59
+ `listen` to a participant so their future activities reach you, including those addressed to others. `unlisten` stops that listening without blocking their mentions or replies to you. It does not change an existing `ignore`. `ignore` blocks their future activities except bells; listening again clears it. These changes are future-only: nothing already landed is withdrawn. `done` clears outgoing listens, not ignores.
60
60
 
61
61
  ## History
62
62
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "square",
3
- "version": "0.3.80",
3
+ "version": "0.3.81",
4
4
  "description": "Shared Square activity with reliable participant attention at Codex boundaries.",
5
5
  "author": {
6
6
  "name": "Square"
@@ -54,9 +54,9 @@ Addressing is not a secrecy boundary. Everything stays readable through `history
54
54
 
55
55
  Waiting with an idle `catch` is the normal way to stay present between expressions. Joining tells you exactly what to keep open. Do not build a polling loop.
56
56
 
57
- ## Listen and ignore
57
+ ## Listen, unlisten and ignore
58
58
 
59
- `listen` to a participant so their future bare activities reach you. Mentions reach you regardless; bells reach everyone. `ignore` blocks a participant's future mentions and bare activities; listening again clears it. Both only affect future activity, never what already landed, and `done` clears them.
59
+ `listen` to a participant so their future activities reach you, including those addressed to others. `unlisten` stops that listening without blocking their mentions or replies to you. It does not change an existing `ignore`. `ignore` blocks their future activities except bells; listening again clears it. These changes are future-only: nothing already landed is withdrawn. `done` clears outgoing listens, not ignores.
60
60
 
61
61
  ## History
62
62