@cleocode/core 2026.9.25 → 2026.10.1
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/admin/import-tasks.d.ts.map +1 -1
- package/dist/admin/import-tasks.js +5 -2
- package/dist/admin/import-tasks.js.map +1 -1
- package/dist/admin/import.d.ts.map +1 -1
- package/dist/admin/import.js +7 -2
- package/dist/admin/import.js.map +1 -1
- package/dist/build-provenance.json +2 -2
- package/dist/cloud/index.d.ts +1 -0
- package/dist/cloud/index.d.ts.map +1 -1
- package/dist/cloud/index.js +1 -0
- package/dist/cloud/index.js.map +1 -1
- package/dist/cloud/nexus-attach.d.ts +94 -0
- package/dist/cloud/nexus-attach.d.ts.map +1 -0
- package/dist/cloud/nexus-attach.js +192 -0
- package/dist/cloud/nexus-attach.js.map +1 -0
- package/dist/cloud/nexus-auth.d.ts +41 -4
- package/dist/cloud/nexus-auth.d.ts.map +1 -1
- package/dist/cloud/nexus-auth.js +151 -38
- package/dist/cloud/nexus-auth.js.map +1 -1
- package/dist/cloud/nexus-cloud-status.d.ts +132 -0
- package/dist/cloud/nexus-cloud-status.d.ts.map +1 -0
- package/dist/cloud/nexus-cloud-status.js +440 -0
- package/dist/cloud/nexus-cloud-status.js.map +1 -0
- package/dist/cloud/nexus-cloud.d.ts +179 -0
- package/dist/cloud/nexus-cloud.d.ts.map +1 -0
- package/dist/cloud/nexus-cloud.js +296 -0
- package/dist/cloud/nexus-cloud.js.map +1 -0
- package/dist/cloud/nexus-credentials.js +2 -2
- package/dist/cloud/nexus-credentials.js.map +1 -1
- package/dist/cloud/nexus-device.d.ts +834 -0
- package/dist/cloud/nexus-device.d.ts.map +1 -0
- package/dist/cloud/nexus-device.js +1727 -0
- package/dist/cloud/nexus-device.js.map +1 -0
- package/dist/cloud/nexus-enrol.d.ts +241 -0
- package/dist/cloud/nexus-enrol.d.ts.map +1 -0
- package/dist/cloud/nexus-enrol.js +1163 -0
- package/dist/cloud/nexus-enrol.js.map +1 -0
- package/dist/cloud/nexus-link.d.ts +10 -0
- package/dist/cloud/nexus-link.d.ts.map +1 -1
- package/dist/cloud/nexus-link.js +172 -11
- package/dist/cloud/nexus-link.js.map +1 -1
- package/dist/cloud/nexus-logout.d.ts +114 -0
- package/dist/cloud/nexus-logout.d.ts.map +1 -0
- package/dist/cloud/nexus-logout.js +429 -0
- package/dist/cloud/nexus-logout.js.map +1 -0
- package/dist/cloud/signing.d.ts +20 -0
- package/dist/cloud/signing.d.ts.map +1 -1
- package/dist/cloud/signing.js +14 -0
- package/dist/cloud/signing.js.map +1 -1
- package/dist/crypto/credentials.d.ts +61 -0
- package/dist/crypto/credentials.d.ts.map +1 -1
- package/dist/crypto/credentials.js +154 -58
- package/dist/crypto/credentials.js.map +1 -1
- package/dist/docs/export-document.js +1072 -227
- package/dist/docs/export-document.js.map +3 -3
- package/dist/doctor/dependency-cycles.d.ts +41 -0
- package/dist/doctor/dependency-cycles.d.ts.map +1 -0
- package/dist/doctor/dependency-cycles.js +39 -0
- package/dist/doctor/dependency-cycles.js.map +1 -0
- package/dist/doctor/index.d.ts +2 -0
- package/dist/doctor/index.d.ts.map +1 -1
- package/dist/doctor/index.js +1 -0
- package/dist/doctor/index.js.map +1 -1
- package/dist/doctor/row-identity.d.ts +11 -0
- package/dist/doctor/row-identity.d.ts.map +1 -1
- package/dist/doctor/row-identity.js +67 -9
- package/dist/doctor/row-identity.js.map +1 -1
- package/dist/error-catalog.d.ts.map +1 -1
- package/dist/error-catalog.js +4 -0
- package/dist/error-catalog.js.map +1 -1
- package/dist/error-registry.d.ts.map +1 -1
- package/dist/error-registry.js +8 -0
- package/dist/error-registry.js.map +1 -1
- package/dist/llm/plugin-facade.js +1200 -355
- package/dist/llm/plugin-facade.js.map +3 -3
- package/dist/nexus/workspace.d.ts.map +1 -1
- package/dist/nexus/workspace.js +54 -4
- package/dist/nexus/workspace.js.map +1 -1
- package/dist/orchestrate/lifecycle-ops.js +1 -1
- package/dist/orchestrate/lifecycle-ops.js.map +1 -1
- package/dist/orchestrate/pivot.d.ts.map +1 -1
- package/dist/orchestrate/pivot.js +4 -2
- package/dist/orchestrate/pivot.js.map +1 -1
- package/dist/orchestrate/query-ops.d.ts.map +1 -1
- package/dist/orchestrate/query-ops.js +3 -3
- package/dist/orchestrate/query-ops.js.map +1 -1
- package/dist/orchestrate/spawn-ops.js +1 -1
- package/dist/orchestrate/spawn-ops.js.map +1 -1
- package/dist/orchestration/index.js +1 -1
- package/dist/orchestration/index.js.map +1 -1
- package/dist/orchestration/lead-rollup.d.ts.map +1 -1
- package/dist/orchestration/lead-rollup.js +3 -3
- package/dist/orchestration/lead-rollup.js.map +1 -1
- package/dist/orchestration/parallel.js +1 -1
- package/dist/orchestration/parallel.js.map +1 -1
- package/dist/orchestration/wave-topic.d.ts +4 -2
- package/dist/orchestration/wave-topic.d.ts.map +1 -1
- package/dist/orchestration/wave-topic.js +6 -4
- package/dist/orchestration/wave-topic.js.map +1 -1
- package/dist/orchestration/waves.d.ts +2 -1
- package/dist/orchestration/waves.d.ts.map +1 -1
- package/dist/orchestration/waves.js +4 -3
- package/dist/orchestration/waves.js.map +1 -1
- package/dist/phases/index.d.ts.map +1 -1
- package/dist/phases/index.js +4 -2
- package/dist/phases/index.js.map +1 -1
- package/dist/selfimprove/fix-gen.js +1203 -358
- package/dist/selfimprove/fix-gen.js.map +3 -3
- package/dist/session/engine-ops.d.ts.map +1 -1
- package/dist/session/engine-ops.js +77 -49
- package/dist/session/engine-ops.js.map +1 -1
- package/dist/sessions/focus-state-store.d.ts +69 -23
- package/dist/sessions/focus-state-store.d.ts.map +1 -1
- package/dist/sessions/focus-state-store.js +113 -27
- package/dist/sessions/focus-state-store.js.map +1 -1
- package/dist/sessions/handoff.js +1 -1
- package/dist/sessions/handoff.js.map +1 -1
- package/dist/sessions/index.d.ts.map +1 -1
- package/dist/sessions/index.js +24 -13
- package/dist/sessions/index.js.map +1 -1
- package/dist/sessions/terminal-identity.d.ts +165 -17
- package/dist/sessions/terminal-identity.d.ts.map +1 -1
- package/dist/sessions/terminal-identity.js +341 -32
- package/dist/sessions/terminal-identity.js.map +1 -1
- package/dist/setup/sections/nexus-account.d.ts +8 -6
- package/dist/setup/sections/nexus-account.d.ts.map +1 -1
- package/dist/setup/sections/nexus-account.js +53 -9
- package/dist/setup/sections/nexus-account.js.map +1 -1
- package/dist/skills/orchestrator/startup.js +2 -2
- package/dist/skills/orchestrator/startup.js.map +1 -1
- package/dist/snapshot/index.d.ts +4 -0
- package/dist/snapshot/index.d.ts.map +1 -1
- package/dist/snapshot/index.js +50 -3
- package/dist/snapshot/index.js.map +1 -1
- package/dist/store/db-helpers.d.ts +15 -2
- package/dist/store/db-helpers.d.ts.map +1 -1
- package/dist/store/db-helpers.js +84 -3
- package/dist/store/db-helpers.js.map +1 -1
- package/dist/store/dependency-cycles.d.ts +164 -0
- package/dist/store/dependency-cycles.d.ts.map +1 -0
- package/dist/store/dependency-cycles.js +360 -0
- package/dist/store/dependency-cycles.js.map +1 -0
- package/dist/store/display-id-alias.d.ts +93 -37
- package/dist/store/display-id-alias.d.ts.map +1 -1
- package/dist/store/display-id-alias.js +590 -115
- package/dist/store/display-id-alias.js.map +1 -1
- package/dist/store/dual-scope-db.d.ts.map +1 -1
- package/dist/store/dual-scope-db.js +34 -7
- package/dist/store/dual-scope-db.js.map +1 -1
- package/dist/store/exodus/migrate.d.ts.map +1 -1
- package/dist/store/exodus/migrate.js +8 -1
- package/dist/store/exodus/migrate.js.map +1 -1
- package/dist/store/global-salt.d.ts +35 -0
- package/dist/store/global-salt.d.ts.map +1 -1
- package/dist/store/global-salt.js +136 -16
- package/dist/store/global-salt.js.map +1 -1
- package/dist/store/migration-sqlite.d.ts.map +1 -1
- package/dist/store/migration-sqlite.js +19 -11
- package/dist/store/migration-sqlite.js.map +1 -1
- package/dist/store/portable-bundle-scan.d.ts.map +1 -1
- package/dist/store/portable-bundle-scan.js +6 -0
- package/dist/store/portable-bundle-scan.js.map +1 -1
- package/dist/store/row-identity-registry.d.ts.map +1 -1
- package/dist/store/row-identity-registry.js +11 -3
- package/dist/store/row-identity-registry.js.map +1 -1
- package/dist/store/row-identity.d.ts +91 -1
- package/dist/store/row-identity.d.ts.map +1 -1
- package/dist/store/row-identity.js +276 -7
- package/dist/store/row-identity.js.map +1 -1
- package/dist/store/safety-data-accessor.d.ts +3 -2
- package/dist/store/safety-data-accessor.d.ts.map +1 -1
- package/dist/store/safety-data-accessor.js +5 -2
- package/dist/store/safety-data-accessor.js.map +1 -1
- package/dist/store/session-store.d.ts +63 -3
- package/dist/store/session-store.d.ts.map +1 -1
- package/dist/store/session-store.js +121 -8
- package/dist/store/session-store.js.map +1 -1
- package/dist/store/split-brain-import.d.ts.map +1 -1
- package/dist/store/split-brain-import.js +36 -0
- package/dist/store/split-brain-import.js.map +1 -1
- package/dist/store/sqlite-data-accessor.d.ts +50 -3
- package/dist/store/sqlite-data-accessor.d.ts.map +1 -1
- package/dist/store/sqlite-data-accessor.js +246 -13
- package/dist/store/sqlite-data-accessor.js.map +1 -1
- package/dist/store/sync/replica.d.ts +34 -3
- package/dist/store/sync/replica.d.ts.map +1 -1
- package/dist/store/sync/replica.js +39 -2
- package/dist/store/sync/replica.js.map +1 -1
- package/dist/store/sync/schema.d.ts +6 -3
- package/dist/store/sync/schema.d.ts.map +1 -1
- package/dist/store/sync/schema.js +6 -3
- package/dist/store/sync/schema.js.map +1 -1
- package/dist/store/tasks-sqlite.d.ts.map +1 -1
- package/dist/store/tasks-sqlite.js +14 -2
- package/dist/store/tasks-sqlite.js.map +1 -1
- package/dist/store/umbrella-data-accessor.d.ts +3 -2
- package/dist/store/umbrella-data-accessor.d.ts.map +1 -1
- package/dist/store/umbrella-data-accessor.js +5 -2
- package/dist/store/umbrella-data-accessor.js.map +1 -1
- package/dist/system/health.d.ts +2 -0
- package/dist/system/health.d.ts.map +1 -1
- package/dist/system/health.js +54 -0
- package/dist/system/health.js.map +1 -1
- package/dist/task-work/index.d.ts.map +1 -1
- package/dist/task-work/index.js +9 -6
- package/dist/task-work/index.js.map +1 -1
- package/dist/tasks/analyze.d.ts.map +1 -1
- package/dist/tasks/analyze.js +3 -2
- package/dist/tasks/analyze.js.map +1 -1
- package/dist/tasks/evidence.d.ts +19 -1
- package/dist/tasks/evidence.d.ts.map +1 -1
- package/dist/tasks/evidence.js +79 -18
- package/dist/tasks/evidence.js.map +1 -1
- package/dist/tasks/infer-add-params.js +1 -1
- package/dist/tasks/infer-add-params.js.map +1 -1
- package/dist/tasks/task-context.d.ts.map +1 -1
- package/dist/tasks/task-context.js +3 -1
- package/dist/tasks/task-context.js.map +1 -1
- package/dist/tasks/task-import.d.ts.map +1 -1
- package/dist/tasks/task-import.js +11 -3
- package/dist/tasks/task-import.js.map +1 -1
- package/dist/tasks/task-next.d.ts +5 -1
- package/dist/tasks/task-next.d.ts.map +1 -1
- package/dist/tasks/task-next.js +7 -2
- package/dist/tasks/task-next.js.map +1 -1
- package/dist/validation/engine-ops.d.ts.map +1 -1
- package/dist/validation/engine-ops.js +3 -2
- package/dist/validation/engine-ops.js.map +1 -1
- package/dist/workgraph/scaffold-apply.d.ts.map +1 -1
- package/dist/workgraph/scaffold-apply.js +14 -5
- package/dist/workgraph/scaffold-apply.js.map +1 -1
- package/migrations/drizzle-cleo-project/20260930160000_t12886-dependency-cycle-guard/migration.sql +76 -0
- package/migrations/drizzle-cleo-project/20260930160000_t12886-dependency-cycle-guard/revert.sql +7 -0
- package/package.json +13 -13
- package/templates/CLEO-INJECTION.md +2 -2
- package/templates/CLEO-REFERENCE.md +1 -1
|
@@ -69,54 +69,98 @@
|
|
|
69
69
|
* @task T12750
|
|
70
70
|
* @epic T12323
|
|
71
71
|
*/
|
|
72
|
-
import { ExitCode } from '@cleocode/contracts';
|
|
72
|
+
import { ExitCode, } from '@cleocode/contracts';
|
|
73
|
+
import { getTableColumns } from 'drizzle-orm';
|
|
73
74
|
import { CleoError } from '../errors.js';
|
|
74
|
-
import {
|
|
75
|
-
import {
|
|
75
|
+
import { exceedsMaxDepth } from '../tasks/hierarchy.js';
|
|
76
|
+
import { resolveHierarchyPolicy } from '../tasks/hierarchy-policy.js';
|
|
77
|
+
import { AC_UID_GRAVEYARD, BIRTH_FP_COLUMN, fillTableUids, markRowIdentityShared, mintRowUid, naturalRowUid, ROW_IDENTITY, registerRowUidFunction, rekeyedChildUid, rowIdentitySpec, UID_COLUMN, } from './row-identity.js';
|
|
78
|
+
import { advanceTaskIdSequence, clearRowUidNative, deleteDisplayIdAliasNative, deleteQuarantineNative, insertDisplayIdAliasNative, insertIdentityRowNative, insertQuarantineNative, insertUidAliasNative, rekeyQuarantineNative, renameTaskDisplayIdNative, rewriteStoredRefUidNative, setNaturalUidNative, setRowUidNative, } from './sqlite-data-accessor.js';
|
|
79
|
+
import { compareHlc as compareSyncHlc, encodeHlc, parseHlc } from './sync/hlc.js';
|
|
80
|
+
import { tasks as tasksTable } from './tasks-schema.js';
|
|
81
|
+
import { insertTaskSchema } from './validation-schemas.js';
|
|
76
82
|
/** Physical name of the display-id alias table. */
|
|
77
83
|
export const DISPLAY_ID_ALIAS_TABLE = 'tasks_display_id_aliases';
|
|
78
84
|
/** Physical name of the uid re-key alias table. */
|
|
79
85
|
export const UID_ALIAS_TABLE = 'tasks_uid_aliases';
|
|
80
86
|
/** Physical name of the identity quarantine (local-only). */
|
|
81
87
|
export const QUARANTINE_TABLE = 'tasks_identity_quarantine';
|
|
82
|
-
|
|
88
|
+
// ---- HLC -------------------------------------------------------------------
|
|
89
|
+
//
|
|
90
|
+
// HLC values are the change journal's (T12342): the wire string `Hlc` in
|
|
91
|
+
// `@cleocode/contracts` (`PPPPPPPPPPPPP-CCCCCC-<replica uuid>`), decoded and
|
|
92
|
+
// ordered by `store/sync/hlc.ts`. String order is HLC order.
|
|
83
93
|
/**
|
|
84
|
-
*
|
|
94
|
+
* Total order of two encoded HLC values (`Hlc` wire strings).
|
|
85
95
|
*
|
|
86
|
-
* @param
|
|
87
|
-
* @
|
|
96
|
+
* @param a - One value.
|
|
97
|
+
* @param b - The other.
|
|
98
|
+
* @returns Negative, zero or positive.
|
|
99
|
+
* @throws HlcError when a value is not an encoded HLC.
|
|
88
100
|
*/
|
|
89
|
-
export function
|
|
90
|
-
return
|
|
101
|
+
export function compareHlc(a, b) {
|
|
102
|
+
return compareSyncHlc(storedHlc(a), storedHlc(b));
|
|
103
|
+
}
|
|
104
|
+
/** Physical milliseconds of an encoded HLC. */
|
|
105
|
+
function hlcMs(value) {
|
|
106
|
+
return storedHlc(value).phys;
|
|
91
107
|
}
|
|
108
|
+
/** The format 9.25 wrote with the uid flag on: `<ms 15>.<counter 6>.<node>`. */
|
|
109
|
+
const LEGACY_HLC_RE = /^(\d{15})\.(\d{6})\.(.+)$/;
|
|
92
110
|
/**
|
|
93
|
-
*
|
|
111
|
+
* Decode an HLC this module stored. The journal format is the only one
|
|
112
|
+
* written; a value in the 9.25 format (written only with
|
|
113
|
+
* `CLEO_ROW_UID_FILL=1`) is still read, ordered by the same
|
|
114
|
+
* `(physical, counter, node)` tuple, so an old alias never breaks a re-mint.
|
|
94
115
|
*
|
|
95
|
-
* @
|
|
96
|
-
* @returns The value.
|
|
97
|
-
* @throws Error when the value is not an encoded HLC.
|
|
116
|
+
* @throws HlcError when the value is in neither format.
|
|
98
117
|
*/
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
if (
|
|
102
|
-
|
|
103
|
-
|
|
118
|
+
function storedHlc(value) {
|
|
119
|
+
const legacy = LEGACY_HLC_RE.exec(value);
|
|
120
|
+
if (legacy) {
|
|
121
|
+
return { phys: Number(legacy[1]), ctr: Number(legacy[2]), replica: legacy[3] };
|
|
122
|
+
}
|
|
123
|
+
return parseHlc(value);
|
|
104
124
|
}
|
|
105
125
|
/**
|
|
106
|
-
*
|
|
126
|
+
* Whether an incoming alias row ranks before the stored one of the same uid
|
|
127
|
+
* (T12800): the least displacement HLC by HLC order (a missing one ranks
|
|
128
|
+
* last), then origin, then `created_at`. The same on every replica.
|
|
129
|
+
*/
|
|
130
|
+
function aliasPrecedes(incoming, stored) {
|
|
131
|
+
const a = incoming.displacedHlc;
|
|
132
|
+
const b = stored.displacedHlc;
|
|
133
|
+
if (a !== null && b === null)
|
|
134
|
+
return true;
|
|
135
|
+
if (a === null && b !== null)
|
|
136
|
+
return false;
|
|
137
|
+
if (a !== null && b !== null) {
|
|
138
|
+
const order = compareHlc(a, b);
|
|
139
|
+
if (order !== 0)
|
|
140
|
+
return order < 0;
|
|
141
|
+
}
|
|
142
|
+
const ao = incoming.origin ?? '';
|
|
143
|
+
const bo = stored.origin ?? '';
|
|
144
|
+
if (ao !== bo)
|
|
145
|
+
return ao < bo;
|
|
146
|
+
return incoming.createdAt < stored.createdAt;
|
|
147
|
+
}
|
|
148
|
+
/** The replica id a pre-HLC value carries: none issued it. */
|
|
149
|
+
export const PRE_HLC_REPLICA = '00000000-0000-0000-0000-000000000000';
|
|
150
|
+
/**
|
|
151
|
+
* The collision HLC of two rows that have no creation HLC (written before the
|
|
152
|
+
* change journal, T12342): the later of their two BIRTHS, at counter 0, from
|
|
153
|
+
* no replica ({@link PRE_HLC_REPLICA}). Every replica derives the same value
|
|
154
|
+
* from the rows' synced births; it only anchors the authority schedule
|
|
155
|
+
* ({@link remintAuthority}), never a clock (HLC never reads a uid's time).
|
|
107
156
|
*
|
|
108
|
-
* @param
|
|
109
|
-
* @param
|
|
110
|
-
* @returns
|
|
157
|
+
* @param birthMsA - One row's birth, epoch ms.
|
|
158
|
+
* @param birthMsB - The other's.
|
|
159
|
+
* @returns An encoded HLC.
|
|
160
|
+
* @task T12802
|
|
111
161
|
*/
|
|
112
|
-
export function
|
|
113
|
-
|
|
114
|
-
const y = parseHlc(b);
|
|
115
|
-
if (x.physicalMs !== y.physicalMs)
|
|
116
|
-
return x.physicalMs < y.physicalMs ? -1 : 1;
|
|
117
|
-
if (x.counter !== y.counter)
|
|
118
|
-
return x.counter < y.counter ? -1 : 1;
|
|
119
|
-
return x.node < y.node ? -1 : x.node > y.node ? 1 : 0;
|
|
162
|
+
export function collisionHlcFromBirths(birthMsA, birthMsB) {
|
|
163
|
+
return encodeHlc({ phys: Math.max(birthMsA, birthMsB, 0), ctr: 0, replica: PRE_HLC_REPLICA });
|
|
120
164
|
}
|
|
121
165
|
// ---- Shared helpers ---------------------------------------------------------
|
|
122
166
|
/** Quote an identifier for SQL. */
|
|
@@ -152,13 +196,6 @@ function inSavepoint(db, name, fn) {
|
|
|
152
196
|
throw error;
|
|
153
197
|
}
|
|
154
198
|
}
|
|
155
|
-
/** A row-identity meta value. */
|
|
156
|
-
function readMeta(db, key) {
|
|
157
|
-
const row = db
|
|
158
|
-
.prepare(`SELECT value FROM main.${q(ROW_IDENTITY_META_TABLE)} WHERE key = ?`)
|
|
159
|
-
.get(key);
|
|
160
|
-
return row?.value;
|
|
161
|
-
}
|
|
162
199
|
/** The local row of a minted table with this uid AND birth fingerprint. */
|
|
163
200
|
function localRow(db, table, uid, birthFp) {
|
|
164
201
|
return db
|
|
@@ -200,7 +237,7 @@ export function remintAuthority(input) {
|
|
|
200
237
|
if (input.cloudSynced)
|
|
201
238
|
return { authority: 'server', reason: 'server', slot: 0 };
|
|
202
239
|
const window = input.takeoverAfterMs ?? REMINT_TAKEOVER_MS;
|
|
203
|
-
const elapsed = Math.max(0,
|
|
240
|
+
const elapsed = Math.max(0, hlcMs(input.atHlc) - hlcMs(input.collisionHlc));
|
|
204
241
|
const active = input.replicas
|
|
205
242
|
.filter((r) => compareHlc(r.joinedHlc, input.atHlc) <= 0 &&
|
|
206
243
|
(r.retiredHlc == null || compareHlc(r.retiredHlc, input.atHlc) > 0))
|
|
@@ -230,10 +267,14 @@ export function remintAuthority(input) {
|
|
|
230
267
|
*/
|
|
231
268
|
export function recordDisplayIdAlias(db, entry) {
|
|
232
269
|
insertDisplayIdAliasNative(db, {
|
|
270
|
+
// The reason is part of the key (T12800 review): a `remint-assigned` and a
|
|
271
|
+
// `collision-remint` alias of one (id, row) are two facts, and neither may
|
|
272
|
+
// win an insert race and drop the other.
|
|
233
273
|
uid: naturalRowUid('project', DISPLAY_ID_ALIAS_TABLE, [
|
|
234
274
|
entry.table,
|
|
235
275
|
entry.displayId,
|
|
236
276
|
entry.entityUid,
|
|
277
|
+
entry.reason,
|
|
237
278
|
]),
|
|
238
279
|
entityTable: entry.table,
|
|
239
280
|
displayId: entry.displayId,
|
|
@@ -242,8 +283,21 @@ export function recordDisplayIdAlias(db, entry) {
|
|
|
242
283
|
reason: entry.reason,
|
|
243
284
|
origin: entry.origin ?? null,
|
|
244
285
|
displacedHlc: entry.displacedHlc ?? null,
|
|
245
|
-
|
|
246
|
-
|
|
286
|
+
// Derived from the displacement's HLC when there is one, so every replica
|
|
287
|
+
// that records this displacement writes the same row (T12800).
|
|
288
|
+
createdAt: entry.now ?? hlcTime(entry.displacedHlc) ?? new Date().toISOString(),
|
|
289
|
+
}, aliasPrecedes);
|
|
290
|
+
}
|
|
291
|
+
/** Wall-clock time of an encoded HLC, or `undefined` when there is none. */
|
|
292
|
+
function hlcTime(hlc) {
|
|
293
|
+
if (!hlc)
|
|
294
|
+
return undefined;
|
|
295
|
+
try {
|
|
296
|
+
return new Date(storedHlc(hlc).phys).toISOString();
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
return undefined;
|
|
300
|
+
}
|
|
247
301
|
}
|
|
248
302
|
/**
|
|
249
303
|
* Resolve a display id: a live row always wins (aliases come back as
|
|
@@ -274,6 +328,9 @@ export function resolveDisplayId(db, table, displayId) {
|
|
|
274
328
|
ORDER BY a.displaced_hlc, a.entity_uid`)
|
|
275
329
|
.all(table, displayId)
|
|
276
330
|
.filter((a) => a.uid !== live?.uid)
|
|
331
|
+
// One claimant per row: several reasons (collision-remint, remint-assigned)
|
|
332
|
+
// of one id and row are one history entry here.
|
|
333
|
+
.filter((a, i, all) => all.findIndex((b) => b.uid === a.uid) === i)
|
|
277
334
|
.map((a) => ({ ...a, via: 'alias' }));
|
|
278
335
|
if (live) {
|
|
279
336
|
return {
|
|
@@ -337,15 +394,29 @@ function taskReferenceColumns(db) {
|
|
|
337
394
|
const tables = new Set(db.prepare("SELECT name FROM main.sqlite_master WHERE type = 'table'").all().map((r) => r.name));
|
|
338
395
|
return [...found.values()].filter((ref) => tables.has(ref.table));
|
|
339
396
|
}
|
|
340
|
-
function remintKey(uid, birthFp) {
|
|
341
|
-
return `remint:tasks_tasks:${uid}:${birthFp}`;
|
|
342
|
-
}
|
|
343
397
|
function readRemint(db, uid, birthFp) {
|
|
344
|
-
const
|
|
345
|
-
|
|
398
|
+
const rows = db
|
|
399
|
+
.prepare(`SELECT display_id AS newId, displaced_hlc AS hlc, origin FROM ${DISPLAY_ID_ALIAS_TABLE}
|
|
400
|
+
WHERE entity_table = 'tasks_tasks' AND entity_uid = ? AND entity_birth_fp = ?
|
|
401
|
+
AND reason = 'remint-assigned' AND displaced_hlc IS NOT NULL`)
|
|
402
|
+
.all(uid, birthFp);
|
|
403
|
+
let best;
|
|
404
|
+
for (const r of rows)
|
|
405
|
+
if (!best || compareHlc(r.hlc, best.hlc) > 0)
|
|
406
|
+
best = r;
|
|
407
|
+
return best;
|
|
346
408
|
}
|
|
409
|
+
/** Record the display id an op assigned (every op, winner or not: its number stays an alias). */
|
|
347
410
|
function writeRemint(db, op) {
|
|
348
|
-
|
|
411
|
+
recordDisplayIdAlias(db, {
|
|
412
|
+
table: 'tasks_tasks',
|
|
413
|
+
displayId: op.newId,
|
|
414
|
+
entityUid: op.uid,
|
|
415
|
+
entityBirthFp: op.birthFp,
|
|
416
|
+
reason: 'remint-assigned',
|
|
417
|
+
origin: op.origin,
|
|
418
|
+
displacedHlc: op.hlc,
|
|
419
|
+
});
|
|
349
420
|
}
|
|
350
421
|
/**
|
|
351
422
|
* AUTHORITY ONLY ({@link remintAuthority}): give the losing task of a
|
|
@@ -389,7 +460,11 @@ export function remintTaskDisplayId(db, taskId, options) {
|
|
|
389
460
|
});
|
|
390
461
|
const op = { uid, birthFp, oldId, newId, origin: options.origin, hlc: options.hlc };
|
|
391
462
|
writeRemint(db, op);
|
|
392
|
-
|
|
463
|
+
const released = releaseHeldRows(db, [
|
|
464
|
+
{ table: 'tasks_tasks', key: taskId },
|
|
465
|
+
{ table: 'tasks_tasks', key: oldId },
|
|
466
|
+
]);
|
|
467
|
+
return { ...op, rewritten, released };
|
|
393
468
|
});
|
|
394
469
|
}
|
|
395
470
|
/**
|
|
@@ -423,8 +498,7 @@ export function applyRemintOp(db, op) {
|
|
|
423
498
|
if (order < 0) {
|
|
424
499
|
inSavepoint(db, 'remint_superseded', () => {
|
|
425
500
|
alias(op.oldId, 'collision-remint');
|
|
426
|
-
|
|
427
|
-
alias(op.newId, 'superseded-remint');
|
|
501
|
+
writeRemint(db, op);
|
|
428
502
|
});
|
|
429
503
|
return { status: 'superseded', winner: prior };
|
|
430
504
|
}
|
|
@@ -437,20 +511,40 @@ export function applyRemintOp(db, op) {
|
|
|
437
511
|
}
|
|
438
512
|
return inSavepoint(db, 'remint_apply', () => {
|
|
439
513
|
alias(op.oldId, 'collision-remint');
|
|
440
|
-
if (prior && prior.newId !== op.newId)
|
|
441
|
-
alias(prior.newId, 'superseded-remint');
|
|
442
514
|
writeRemint(db, op);
|
|
443
|
-
if (!live)
|
|
444
|
-
|
|
515
|
+
if (!live) {
|
|
516
|
+
const held = { entityTable: 'tasks_tasks', uid: op.uid, birthFp: op.birthFp };
|
|
517
|
+
return {
|
|
518
|
+
status: 'recorded',
|
|
519
|
+
released: releaseHeldRows(db, [{ table: 'tasks_tasks', held }]),
|
|
520
|
+
};
|
|
521
|
+
}
|
|
445
522
|
if (live.key === op.newId)
|
|
446
523
|
return { status: 'already-applied' };
|
|
447
524
|
const { rewritten } = renameTaskDisplayIdNative(db, op.uid, op.newId, taskReferenceColumns(db));
|
|
448
525
|
return {
|
|
449
526
|
status: 'applied',
|
|
450
|
-
receipt: {
|
|
527
|
+
receipt: {
|
|
528
|
+
...op,
|
|
529
|
+
rewritten,
|
|
530
|
+
released: releaseHeldRows(db, [{ table: 'tasks_tasks', key: live.key }]),
|
|
531
|
+
},
|
|
451
532
|
};
|
|
452
533
|
});
|
|
453
534
|
}
|
|
535
|
+
/**
|
|
536
|
+
* Version of the wire row format. v2 (T12798) carries no local key of
|
|
537
|
+
* another row in `values`; a row without it (or older) came from a build
|
|
538
|
+
* whose values hold raw local keys, and is held (`unsupported-wire`) rather
|
|
539
|
+
* than applied or silently stripped.
|
|
540
|
+
*/
|
|
541
|
+
export const WIRE_VERSION = 2;
|
|
542
|
+
/**
|
|
543
|
+
* AC-history rows whose criterion is gone everywhere are still history: they
|
|
544
|
+
* are placed with the criterion's uid carried and a key that names no live
|
|
545
|
+
* criterion (`gone:<uid>`), never held forever.
|
|
546
|
+
*/
|
|
547
|
+
const GONE_CRITERION_PREFIX = 'gone:';
|
|
454
548
|
/** Follow `tasks_uid_aliases` from (uid, birth fp) to the row's current uid. */
|
|
455
549
|
function followUidAlias(db, table, uid, birthFp) {
|
|
456
550
|
let current = uid;
|
|
@@ -479,13 +573,20 @@ function followUidAlias(db, table, uid, birthFp) {
|
|
|
479
573
|
* @returns Where it points.
|
|
480
574
|
*/
|
|
481
575
|
export function resolveWireRef(db, table, ref) {
|
|
576
|
+
if (ref.uid === null)
|
|
577
|
+
return { status: 'pending' };
|
|
482
578
|
const minted = specOf(table).kind === 'minted';
|
|
579
|
+
// A minted row is identified by uid AND fingerprint: a reference without a
|
|
580
|
+
// fingerprint never matches one (the other side of a uid collision holds the
|
|
581
|
+
// same uid).
|
|
582
|
+
if (minted && ref.birthFp === null)
|
|
583
|
+
return { status: 'pending' };
|
|
483
584
|
const uid = minted && ref.birthFp !== null ? followUidAlias(db, table, ref.uid, ref.birthFp) : ref.uid;
|
|
484
585
|
const row = db
|
|
485
586
|
.prepare(`SELECT ${q(keyOf(table))} AS key, ${minted ? q(BIRTH_FP_COLUMN) : 'NULL'} AS fp
|
|
486
587
|
FROM main.${q(table)} WHERE ${q(UID_COLUMN)} = ?`)
|
|
487
588
|
.get(uid);
|
|
488
|
-
if (row && (!minted ||
|
|
589
|
+
if (row && (!minted || row.fp === ref.birthFp)) {
|
|
489
590
|
return { status: 'row', key: row.key, uid };
|
|
490
591
|
}
|
|
491
592
|
const held = db
|
|
@@ -493,6 +594,13 @@ export function resolveWireRef(db, table, ref) {
|
|
|
493
594
|
.get(table, uid, ref.birthFp ?? '');
|
|
494
595
|
return held ? { status: 'held' } : { status: 'pending' };
|
|
495
596
|
}
|
|
597
|
+
/** Whether a column accepts NULL. */
|
|
598
|
+
function nullableColumn(db, table, column) {
|
|
599
|
+
const info = db
|
|
600
|
+
.prepare('SELECT "notnull" AS notnull, pk FROM pragma_table_info(?) WHERE name = ?')
|
|
601
|
+
.get(table, column);
|
|
602
|
+
return info !== undefined && info.notnull === 0 && info.pk === 0;
|
|
603
|
+
}
|
|
496
604
|
/** Reference column → referenced table, for a declared table. */
|
|
497
605
|
function refTargets(spec) {
|
|
498
606
|
const out = new Map();
|
|
@@ -501,21 +609,57 @@ function refTargets(spec) {
|
|
|
501
609
|
}
|
|
502
610
|
return out;
|
|
503
611
|
}
|
|
612
|
+
/** Stored reference uid column (`ac_uid`) → its table and key column (`ac_id`). */
|
|
613
|
+
function storedUidRefs(spec) {
|
|
614
|
+
const out = new Map();
|
|
615
|
+
for (const ref of spec.storedRefUids ?? []) {
|
|
616
|
+
if ((ref.source ?? 'uid') === 'uid')
|
|
617
|
+
out.set(ref.column, { table: ref.table, from: ref.from });
|
|
618
|
+
}
|
|
619
|
+
return out;
|
|
620
|
+
}
|
|
621
|
+
/** JSON array of ids column → referenced table. */
|
|
622
|
+
function jsonArrayTargets(spec) {
|
|
623
|
+
return new Map((spec.jsonArrayRefs ?? []).map((ref) => [ref.column, ref.table]));
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* Columns that hold another row's LOCAL key: never sent as values (T12798).
|
|
627
|
+
* `ac_text_hash` (a stored fact, not a key) is kept.
|
|
628
|
+
*/
|
|
629
|
+
function localKeyColumns(spec) {
|
|
630
|
+
const out = new Set([...refTargets(spec).keys(), ...jsonArrayTargets(spec).keys()]);
|
|
631
|
+
for (const [column, { from }] of storedUidRefs(spec)) {
|
|
632
|
+
out.add(column);
|
|
633
|
+
out.add(from);
|
|
634
|
+
}
|
|
635
|
+
return out;
|
|
636
|
+
}
|
|
504
637
|
/** Hold a row, keeping the time it was first held. */
|
|
505
638
|
function hold(db, wire, reason, contestedId, previous) {
|
|
506
639
|
const key = { entityTable: wire.table, uid: wire.uid, birthFp: wire.birthFp ?? '' };
|
|
507
640
|
const was = previous ?? key;
|
|
641
|
+
const rowJson = JSON.stringify(wire);
|
|
508
642
|
const first = db
|
|
509
|
-
.prepare(`SELECT created_at AS createdAt
|
|
510
|
-
|
|
643
|
+
.prepare(`SELECT created_at AS createdAt, reason, contested_id AS contestedId, row_json AS rowJson
|
|
644
|
+
FROM ${QUARANTINE_TABLE} WHERE entity_table = ? AND uid = ? AND birth_fp = ?`)
|
|
511
645
|
.get(was.entityTable, was.uid, was.birthFp);
|
|
646
|
+
// A still-held row that is held for the same reason is never rewritten (T12801).
|
|
647
|
+
if (first &&
|
|
648
|
+
was.entityTable === key.entityTable &&
|
|
649
|
+
was.uid === key.uid &&
|
|
650
|
+
was.birthFp === key.birthFp &&
|
|
651
|
+
first.reason === reason &&
|
|
652
|
+
first.contestedId === contestedId &&
|
|
653
|
+
first.rowJson === rowJson) {
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
512
656
|
if (previous)
|
|
513
657
|
deleteQuarantineNative(db, previous);
|
|
514
658
|
insertQuarantineNative(db, {
|
|
515
659
|
...key,
|
|
516
660
|
reason,
|
|
517
661
|
contestedId,
|
|
518
|
-
rowJson
|
|
662
|
+
rowJson,
|
|
519
663
|
receivedHlc: null,
|
|
520
664
|
createdAt: first?.createdAt ?? new Date().toISOString(),
|
|
521
665
|
});
|
|
@@ -524,6 +668,13 @@ function hold(db, wire, reason, contestedId, previous) {
|
|
|
524
668
|
function placeRow(db, incoming, heldAs) {
|
|
525
669
|
const spec = specOf(incoming.table);
|
|
526
670
|
const minted = spec.kind === 'minted';
|
|
671
|
+
// A v1 sender's values hold raw local keys of other rows: recognised and
|
|
672
|
+
// held, never applied and never silently stripped (T12798 review).
|
|
673
|
+
const version = incoming.version ?? 1;
|
|
674
|
+
if (version !== WIRE_VERSION) {
|
|
675
|
+
hold(db, incoming, 'unsupported-wire', `wire v${version}`, heldAs);
|
|
676
|
+
return { status: 'held', reason: 'unsupported-wire' };
|
|
677
|
+
}
|
|
527
678
|
if (minted && !incoming.birthFp) {
|
|
528
679
|
throw new Error(`receive: a ${incoming.table} row must carry its birth fingerprint`);
|
|
529
680
|
}
|
|
@@ -569,21 +720,67 @@ function placeRow(db, incoming, heldAs) {
|
|
|
569
720
|
};
|
|
570
721
|
}
|
|
571
722
|
const values = { ...wire.values };
|
|
723
|
+
// A local key of another row never comes from the sender's values (T12798);
|
|
724
|
+
// a JSON column that is not an id array travels as a value.
|
|
725
|
+
const arrayColumns = jsonArrayTargets(spec);
|
|
726
|
+
for (const column of localKeyColumns(spec)) {
|
|
727
|
+
if (!arrayColumns.has(column) || wire.jsonRefs?.[column])
|
|
728
|
+
delete values[column];
|
|
729
|
+
}
|
|
572
730
|
const targets = refTargets(spec);
|
|
731
|
+
const stored = storedUidRefs(spec);
|
|
732
|
+
const pending = () => {
|
|
733
|
+
hold(db, wire, 'ref-pending', null, heldAs);
|
|
734
|
+
return { status: 'held', reason: 'ref-pending' };
|
|
735
|
+
};
|
|
573
736
|
for (const [column, ref] of Object.entries(wire.refs ?? {})) {
|
|
574
|
-
const
|
|
737
|
+
const storedRef = stored.get(column);
|
|
738
|
+
const target = targets.get(column) ?? storedRef?.table;
|
|
575
739
|
if (!target)
|
|
576
740
|
throw new Error(`receive: ${wire.table}.${column} is not a declared reference`);
|
|
577
741
|
if (ref === null) {
|
|
578
742
|
values[column] = null;
|
|
743
|
+
if (storedRef)
|
|
744
|
+
values[storedRef.from] = null;
|
|
579
745
|
continue;
|
|
580
746
|
}
|
|
581
747
|
const resolved = resolveWireRef(db, target, ref);
|
|
582
|
-
if (resolved.status !== 'row') {
|
|
583
|
-
|
|
584
|
-
|
|
748
|
+
if (resolved.status !== 'row' && ref.gone) {
|
|
749
|
+
// The sender no longer has the row this names, and neither do we.
|
|
750
|
+
if (storedRef && wire.table === 'tasks_task_acceptance_criteria_history') {
|
|
751
|
+
// History of a criterion gone everywhere: keep the history, carry the
|
|
752
|
+
// criterion's uid, point the key at no live criterion.
|
|
753
|
+
values[column] = ref.uid;
|
|
754
|
+
values[storedRef.from] = `${GONE_CRITERION_PREFIX}${ref.uid ?? wire.uid}`;
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
if (!storedRef && nullableColumn(db, wire.table, column)) {
|
|
758
|
+
values[column] = null; // a dangling optional reference is dropped
|
|
759
|
+
continue;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
if (resolved.status !== 'row')
|
|
763
|
+
return pending();
|
|
764
|
+
if (storedRef) {
|
|
765
|
+
values[storedRef.from] = resolved.key;
|
|
766
|
+
values[column] = resolved.uid;
|
|
767
|
+
}
|
|
768
|
+
else {
|
|
769
|
+
values[column] = resolved.key;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
for (const [column, refs] of Object.entries(wire.jsonRefs ?? {})) {
|
|
773
|
+
const target = arrayColumns.get(column);
|
|
774
|
+
if (!target)
|
|
775
|
+
throw new Error(`receive: ${wire.table}.${column} is not a declared id array`);
|
|
776
|
+
const keys = [];
|
|
777
|
+
for (const ref of refs) {
|
|
778
|
+
const resolved = resolveWireRef(db, target, ref);
|
|
779
|
+
if (resolved.status !== 'row')
|
|
780
|
+
return pending();
|
|
781
|
+
keys.push(resolved.key);
|
|
585
782
|
}
|
|
586
|
-
values[column] =
|
|
783
|
+
values[column] = JSON.stringify(keys);
|
|
587
784
|
}
|
|
588
785
|
if (spec.displayId && minted) {
|
|
589
786
|
const key = keyOf(wire.table);
|
|
@@ -610,6 +807,33 @@ function placeRow(db, incoming, heldAs) {
|
|
|
610
807
|
};
|
|
611
808
|
}
|
|
612
809
|
}
|
|
810
|
+
if (wire.table === DISPLAY_ID_ALIAS_TABLE || wire.table === UID_ALIAS_TABLE) {
|
|
811
|
+
// An alias row's uid IS its primary key, computed on write from its key
|
|
812
|
+
// values (none is a reference): derive it the same way here (T12800).
|
|
813
|
+
const uid = naturalRowUid('project', wire.table, spec.key.map((k) => (values[k] ?? null)));
|
|
814
|
+
const existed = db.prepare(`SELECT 1 AS x FROM main.${q(wire.table)} WHERE ${q(UID_COLUMN)} = ?`).get(uid) !==
|
|
815
|
+
undefined;
|
|
816
|
+
if (wire.table === DISPLAY_ID_ALIAS_TABLE) {
|
|
817
|
+
// The order-independent upsert: whichever copy arrives first, every
|
|
818
|
+
// replica keeps the same row.
|
|
819
|
+
insertDisplayIdAliasNative(db, {
|
|
820
|
+
uid,
|
|
821
|
+
entityTable: String(values['entity_table']),
|
|
822
|
+
displayId: String(values['display_id']),
|
|
823
|
+
entityUid: String(values['entity_uid']),
|
|
824
|
+
entityBirthFp: values['entity_birth_fp'] ?? null,
|
|
825
|
+
reason: String(values['reason']),
|
|
826
|
+
origin: values['origin'] ?? null,
|
|
827
|
+
displacedHlc: values['displaced_hlc'] ?? null,
|
|
828
|
+
createdAt: String(values['created_at']),
|
|
829
|
+
}, aliasPrecedes);
|
|
830
|
+
}
|
|
831
|
+
else if (!existed) {
|
|
832
|
+
insertIdentityRowNative(db, wire.table, { ...values, [UID_COLUMN]: uid });
|
|
833
|
+
}
|
|
834
|
+
done();
|
|
835
|
+
return existed ? { status: 'duplicate', uid } : { status: 'inserted', key: uid, uid };
|
|
836
|
+
}
|
|
613
837
|
if (!minted) {
|
|
614
838
|
const existing = db
|
|
615
839
|
.prepare(`SELECT ${q(UID_COLUMN)} AS uid FROM main.${q(wire.table)}
|
|
@@ -637,17 +861,38 @@ function placeRow(db, incoming, heldAs) {
|
|
|
637
861
|
return { status: 'held', reason: 'key-collision' };
|
|
638
862
|
}
|
|
639
863
|
}
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
864
|
+
// Validate like a local writer would (T12801): a received row is data from
|
|
865
|
+
// another device, never trusted to satisfy this store's invariants.
|
|
866
|
+
const invalid = validateReceived(db, wire.table, values);
|
|
867
|
+
if (invalid) {
|
|
868
|
+
hold(db, wire, 'invalid', invalid, heldAs);
|
|
869
|
+
return { status: 'held', reason: 'invalid' };
|
|
870
|
+
}
|
|
871
|
+
try {
|
|
872
|
+
inSavepoint(db, 'receive_insert', () => {
|
|
873
|
+
if (minted) {
|
|
874
|
+
insertIdentityRowNative(db, wire.table, {
|
|
875
|
+
...values,
|
|
876
|
+
[UID_COLUMN]: wire.uid,
|
|
877
|
+
[BIRTH_FP_COLUMN]: wire.birthFp,
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
else {
|
|
881
|
+
// A natural uid is a pure function of its endpoints: derive it here.
|
|
882
|
+
insertIdentityRowNative(db, wire.table, { ...values, [UID_COLUMN]: null });
|
|
883
|
+
fillTableUids(db, 'project', wire.table);
|
|
884
|
+
}
|
|
645
885
|
});
|
|
646
886
|
}
|
|
647
|
-
|
|
648
|
-
//
|
|
649
|
-
|
|
650
|
-
|
|
887
|
+
catch (error) {
|
|
888
|
+
// The store's own guards refused it (hierarchy cycle and type-matrix
|
|
889
|
+
// triggers, CHECK and NOT NULL constraints): hold it, never throw the
|
|
890
|
+
// merge away, never insert it.
|
|
891
|
+
if (!isConstraintError(error))
|
|
892
|
+
throw error;
|
|
893
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
894
|
+
hold(db, wire, 'invalid', message.slice(0, 200), heldAs);
|
|
895
|
+
return { status: 'held', reason: 'invalid' };
|
|
651
896
|
}
|
|
652
897
|
done();
|
|
653
898
|
const key = rowidKey
|
|
@@ -665,31 +910,123 @@ function rowidKeyOf(db, table) {
|
|
|
665
910
|
const [only] = pk;
|
|
666
911
|
return pk.length === 1 && only && only.type.toUpperCase() === 'INTEGER' ? only.name : undefined;
|
|
667
912
|
}
|
|
913
|
+
/** Held rows a trigger may unblock (indexed lookups and a JSON text match, never a full retry). */
|
|
914
|
+
function candidatesFor(db, t) {
|
|
915
|
+
const cols = `entity_table AS entityTable, uid, birth_fp AS birthFp, row_json AS rowJson`;
|
|
916
|
+
const out = [];
|
|
917
|
+
if (t.held) {
|
|
918
|
+
const row = db
|
|
919
|
+
.prepare(`SELECT ${cols} FROM ${QUARANTINE_TABLE} WHERE entity_table = ? AND uid = ? AND birth_fp = ?`)
|
|
920
|
+
.get(t.held.entityTable, t.held.uid, t.held.birthFp);
|
|
921
|
+
if (row)
|
|
922
|
+
out.push(row);
|
|
923
|
+
}
|
|
924
|
+
if (t.key !== undefined) {
|
|
925
|
+
out.push(...db
|
|
926
|
+
.prepare(`SELECT ${cols} FROM ${QUARANTINE_TABLE} WHERE entity_table = ? AND contested_id = ?
|
|
927
|
+
ORDER BY created_at, uid`)
|
|
928
|
+
.all(t.table, t.key));
|
|
929
|
+
}
|
|
930
|
+
if (t.uid !== undefined) {
|
|
931
|
+
// Rows waiting on a reference to this uid carry it in their wire form,
|
|
932
|
+
// either as this uid or as an old uid a re-key led here.
|
|
933
|
+
const uids = new Set([t.uid]);
|
|
934
|
+
for (let frontier = [t.uid]; frontier.length > 0;) {
|
|
935
|
+
const older = db
|
|
936
|
+
.prepare(`SELECT old_uid AS uid FROM ${UID_ALIAS_TABLE}
|
|
937
|
+
WHERE entity_table = ? AND new_uid IN (${frontier.map(() => '?').join(', ')})`)
|
|
938
|
+
.all(t.table, ...frontier);
|
|
939
|
+
frontier = older.map((r) => r.uid).filter((u) => !uids.has(u));
|
|
940
|
+
for (const u of frontier)
|
|
941
|
+
uids.add(u);
|
|
942
|
+
}
|
|
943
|
+
const find = db.prepare(`SELECT ${cols} FROM ${QUARANTINE_TABLE} WHERE instr(row_json, ?) > 0 AND uid <> ?
|
|
944
|
+
ORDER BY created_at, uid`);
|
|
945
|
+
for (const uid of uids) {
|
|
946
|
+
out.push(...find.all(`"${uid}"`, t.uid));
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
return out;
|
|
950
|
+
}
|
|
668
951
|
/**
|
|
669
|
-
* Re-try
|
|
670
|
-
* (a
|
|
952
|
+
* Re-try the held rows the triggers may unblock, then the rows each placement
|
|
953
|
+
* unblocks in turn (a placed owner unblocks its children). A held row is only
|
|
954
|
+
* re-tried when something it may wait on changed, and a row still held for
|
|
955
|
+
* the same reason is not rewritten (T12801: no full re-scan per receive).
|
|
956
|
+
* `'all'` re-tries every held row once (explicit {@link releaseHeld}).
|
|
671
957
|
*/
|
|
672
|
-
function releaseHeldRows(db) {
|
|
958
|
+
function releaseHeldRows(db, triggers) {
|
|
673
959
|
const released = [];
|
|
674
|
-
|
|
960
|
+
const work = [];
|
|
961
|
+
const attempt = (h) => {
|
|
962
|
+
const key = { entityTable: h.entityTable, uid: h.uid, birthFp: h.birthFp };
|
|
963
|
+
const result = placeRow(db, JSON.parse(h.rowJson), key);
|
|
964
|
+
if (result.status === 'held')
|
|
965
|
+
return;
|
|
966
|
+
released.push(key);
|
|
967
|
+
if (result.status === 'inserted') {
|
|
968
|
+
work.push({ table: h.entityTable, uid: result.uid, key: result.key });
|
|
969
|
+
}
|
|
970
|
+
};
|
|
971
|
+
if (triggers === 'all') {
|
|
675
972
|
const held = db
|
|
676
973
|
.prepare(`SELECT entity_table AS entityTable, uid, birth_fp AS birthFp, row_json AS rowJson
|
|
677
974
|
FROM ${QUARANTINE_TABLE} ORDER BY created_at, entity_table, uid`)
|
|
678
975
|
.all();
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
976
|
+
for (const h of held)
|
|
977
|
+
attempt(h);
|
|
978
|
+
}
|
|
979
|
+
else {
|
|
980
|
+
work.push(...triggers);
|
|
981
|
+
}
|
|
982
|
+
for (let next = work.shift(); next !== undefined; next = work.shift()) {
|
|
983
|
+
const seen = new Set();
|
|
984
|
+
for (const h of candidatesFor(db, next)) {
|
|
985
|
+
const id = `${h.entityTable}\u0000${h.uid}\u0000${h.birthFp}`;
|
|
986
|
+
if (seen.has(id))
|
|
987
|
+
continue;
|
|
988
|
+
seen.add(id);
|
|
989
|
+
attempt(h);
|
|
687
990
|
}
|
|
688
|
-
if (!moved)
|
|
689
|
-
break;
|
|
690
991
|
}
|
|
691
992
|
return released;
|
|
692
993
|
}
|
|
994
|
+
/** Release triggers of a re-key receipt: its moved rows and the uids they left and took. */
|
|
995
|
+
function rekeyTriggers(receipt) {
|
|
996
|
+
const out = [
|
|
997
|
+
{ table: receipt.table, uid: receipt.oldUid },
|
|
998
|
+
{ table: receipt.table, uid: receipt.newUid },
|
|
999
|
+
{
|
|
1000
|
+
table: receipt.table,
|
|
1001
|
+
held: { entityTable: receipt.table, uid: receipt.newUid, birthFp: receipt.birthFp },
|
|
1002
|
+
},
|
|
1003
|
+
];
|
|
1004
|
+
for (const child of receipt.cascaded)
|
|
1005
|
+
out.push(...rekeyTriggers(child));
|
|
1006
|
+
return out;
|
|
1007
|
+
}
|
|
1008
|
+
/** The policy each connection receives under; set by {@link receiveRow}'s `policy`. */
|
|
1009
|
+
const receivePolicies = new WeakMap();
|
|
1010
|
+
/**
|
|
1011
|
+
* Resolve the receive policy of a project from its configuration, exactly as
|
|
1012
|
+
* a local task write does (`resolveHierarchyPolicy(loadConfig())`, so
|
|
1013
|
+
* `hierarchy.maxDepth` and `CLEO_HIERARCHY_MAX_DEPTH` apply). The merge engine
|
|
1014
|
+
* resolves it once per merge and passes it to {@link receiveRow}.
|
|
1015
|
+
*
|
|
1016
|
+
* @param cwd - Project root.
|
|
1017
|
+
* @returns The policy.
|
|
1018
|
+
*/
|
|
1019
|
+
export async function loadReceivePolicy(cwd) {
|
|
1020
|
+
// Loaded lazily: config.js is outside the store module graph.
|
|
1021
|
+
const { loadConfig } = await import('../config.js');
|
|
1022
|
+
return { maxDepth: resolveHierarchyPolicy(await loadConfig(cwd)).maxDepth };
|
|
1023
|
+
}
|
|
1024
|
+
/** The policy in force on `db`: the last one passed, else the default profile's. */
|
|
1025
|
+
function receivePolicyOf(db) {
|
|
1026
|
+
return (receivePolicies.get(db) ?? {
|
|
1027
|
+
maxDepth: resolveHierarchyPolicy({}).maxDepth,
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
693
1030
|
/**
|
|
694
1031
|
* Receive one row: place it, or hold it (module docs). The first receive
|
|
695
1032
|
* marks the store's identity values as shared, so the open pass never
|
|
@@ -697,19 +1034,90 @@ function releaseHeldRows(db) {
|
|
|
697
1034
|
*
|
|
698
1035
|
* @param db - Connection on the project `cleo.db`, inside the merge transaction.
|
|
699
1036
|
* @param wire - The incoming row.
|
|
1037
|
+
* @param policy - The project's resolved receive policy ({@link loadReceivePolicy}).
|
|
1038
|
+
* It stays in force on this connection, also for held rows released later;
|
|
1039
|
+
* without one, the default hierarchy profile applies.
|
|
700
1040
|
* @returns What happened; a `held` result carries the collision for the caller
|
|
701
1041
|
* (the merge engine) to hand to its authority.
|
|
702
1042
|
*/
|
|
703
|
-
export function receiveRow(db, wire) {
|
|
1043
|
+
export function receiveRow(db, wire, policy) {
|
|
1044
|
+
if (policy)
|
|
1045
|
+
receivePolicies.set(db, policy);
|
|
704
1046
|
registerRowUidFunction(db, 'project');
|
|
705
1047
|
return inSavepoint(db, 'receive', () => {
|
|
706
1048
|
markRowIdentityShared(db, 'receive');
|
|
707
1049
|
const result = placeRow(db, wire);
|
|
708
|
-
if (result.status === 'inserted')
|
|
709
|
-
releaseHeldRows(db);
|
|
1050
|
+
if (result.status === 'inserted') {
|
|
1051
|
+
releaseHeldRows(db, [{ table: wire.table, uid: result.uid, key: result.key }]);
|
|
1052
|
+
}
|
|
710
1053
|
return result;
|
|
711
1054
|
});
|
|
712
1055
|
}
|
|
1056
|
+
/** Depth of a local task (a root is 0), walking its parent chain. */
|
|
1057
|
+
function depthOf(db, taskId) {
|
|
1058
|
+
const parentOf = db.prepare('SELECT parent_id AS p FROM tasks_tasks WHERE id = ?');
|
|
1059
|
+
let depth = 0;
|
|
1060
|
+
let current = taskId;
|
|
1061
|
+
for (let hop = 0; hop < 64 && current !== null; hop++) {
|
|
1062
|
+
const row = parentOf.get(current);
|
|
1063
|
+
current = row?.p ?? null;
|
|
1064
|
+
if (current !== null)
|
|
1065
|
+
depth++;
|
|
1066
|
+
}
|
|
1067
|
+
return depth;
|
|
1068
|
+
}
|
|
1069
|
+
/**
|
|
1070
|
+
* Whether an insert error is the store refusing the row: SQLite's
|
|
1071
|
+
* SQLITE_CONSTRAINT family (CHECK, NOT NULL, UNIQUE, FOREIGN KEY, and the
|
|
1072
|
+
* guard triggers' `RAISE(ABORT, ...)`, which report SQLITE_CONSTRAINT_TRIGGER).
|
|
1073
|
+
* Anything else (a missing table, an I/O error) is not the row's fault and
|
|
1074
|
+
* is thrown.
|
|
1075
|
+
*/
|
|
1076
|
+
function isConstraintError(error) {
|
|
1077
|
+
const code = error?.errcode;
|
|
1078
|
+
return typeof code === 'number' && (code & 0xff) === 19; // SQLITE_CONSTRAINT
|
|
1079
|
+
}
|
|
1080
|
+
/** The claim lease columns: local to the device that took the lease, never received. */
|
|
1081
|
+
const CLAIM_COLUMNS = ['claimed_by_session', 'claimed_by_agent', 'claimed_at', 'lease_expires_at'];
|
|
1082
|
+
/**
|
|
1083
|
+
* Per-table validation of a received row, in place (T12801). A task is checked
|
|
1084
|
+
* against `insertTaskSchema` (the schema every local task write satisfies) and
|
|
1085
|
+
* arrives without a claim lease: a lease belongs to the device and session
|
|
1086
|
+
* that took it. The store's triggers (containment cycle, type matrix, status
|
|
1087
|
+
* pipeline) then run on the insert itself.
|
|
1088
|
+
*
|
|
1089
|
+
* @returns Why the row is invalid, or `null`.
|
|
1090
|
+
*/
|
|
1091
|
+
function validateReceived(db, table, values) {
|
|
1092
|
+
if (table !== 'tasks_tasks')
|
|
1093
|
+
return null;
|
|
1094
|
+
// The containment depth cap every local task write enforces, from the
|
|
1095
|
+
// project's resolved hierarchy policy (T12801 review).
|
|
1096
|
+
const parent = values.parent_id;
|
|
1097
|
+
if (typeof parent === 'string' && parent.length > 0) {
|
|
1098
|
+
const { maxDepth } = receivePolicyOf(db);
|
|
1099
|
+
const parentDepth = depthOf(db, parent);
|
|
1100
|
+
if (exceedsMaxDepth(parentDepth, maxDepth)) {
|
|
1101
|
+
return `E_DEPTH_EXCEEDED: depth ${parentDepth + 1} under ${parent} exceeds ${maxDepth}`;
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
for (const column of CLAIM_COLUMNS) {
|
|
1105
|
+
if (column in values)
|
|
1106
|
+
values[column] = null;
|
|
1107
|
+
}
|
|
1108
|
+
const byColumn = new Map(Object.entries(getTableColumns(tasksTable)).map(([prop, col]) => [col.name, prop]));
|
|
1109
|
+
const candidate = {};
|
|
1110
|
+
for (const [column, value] of Object.entries(values)) {
|
|
1111
|
+
const prop = byColumn.get(column);
|
|
1112
|
+
if (prop !== undefined && value !== null)
|
|
1113
|
+
candidate[prop] = value;
|
|
1114
|
+
}
|
|
1115
|
+
const parsed = insertTaskSchema.safeParse(candidate);
|
|
1116
|
+
if (parsed.success)
|
|
1117
|
+
return null;
|
|
1118
|
+
const issue = parsed.error.issues[0];
|
|
1119
|
+
return `invalid ${issue?.path.join('.') ?? 'row'}: ${issue?.message ?? 'rejected'}`.slice(0, 200);
|
|
1120
|
+
}
|
|
713
1121
|
/**
|
|
714
1122
|
* Re-try the held rows now (after an op this module did not apply).
|
|
715
1123
|
*
|
|
@@ -718,7 +1126,7 @@ export function receiveRow(db, wire) {
|
|
|
718
1126
|
*/
|
|
719
1127
|
export function releaseHeld(db) {
|
|
720
1128
|
registerRowUidFunction(db, 'project');
|
|
721
|
-
return inSavepoint(db, 'release', () => releaseHeldRows(db));
|
|
1129
|
+
return inSavepoint(db, 'release', () => releaseHeldRows(db, 'all'));
|
|
722
1130
|
}
|
|
723
1131
|
/**
|
|
724
1132
|
* Every held row (read-only: held rows are never edited in place).
|
|
@@ -748,32 +1156,91 @@ export function wireRowOf(db, table, uid) {
|
|
|
748
1156
|
const row = db.prepare(`SELECT * FROM main.${q(table)} WHERE ${q(UID_COLUMN)} = ?`).get(uid);
|
|
749
1157
|
if (!row)
|
|
750
1158
|
throw new Error(`wire: no ${table} row with uid ${uid}`);
|
|
1159
|
+
const keyColumns = localKeyColumns(spec);
|
|
751
1160
|
const values = {};
|
|
752
1161
|
for (const [column, value] of Object.entries(row)) {
|
|
753
|
-
if (column
|
|
754
|
-
|
|
1162
|
+
if (column === UID_COLUMN || column === BIRTH_FP_COLUMN || keyColumns.has(column))
|
|
1163
|
+
continue;
|
|
1164
|
+
values[column] = value;
|
|
755
1165
|
}
|
|
1166
|
+
/** The identity of the target row, by its local key or by its uid. */
|
|
1167
|
+
const identityOf = (target, by, value) => {
|
|
1168
|
+
const minted = specOf(target).kind === 'minted';
|
|
1169
|
+
const t = db
|
|
1170
|
+
.prepare(`SELECT ${q(UID_COLUMN)} AS uid, ${minted ? q(BIRTH_FP_COLUMN) : 'NULL'} AS fp
|
|
1171
|
+
FROM main.${q(target)} WHERE ${q(by === 'key' ? keyOf(target) : UID_COLUMN)} = ?`)
|
|
1172
|
+
.get(value);
|
|
1173
|
+
// A missing target travels as gone (the raw key never travels); one whose
|
|
1174
|
+
// uid is not filled yet travels as uid null (the receiver waits).
|
|
1175
|
+
if (!t)
|
|
1176
|
+
return { uid: null, birthFp: null, gone: true };
|
|
1177
|
+
return t.uid ? { uid: t.uid, birthFp: t.fp } : { uid: null, birthFp: null };
|
|
1178
|
+
};
|
|
1179
|
+
/** The fingerprint a deleted criterion had, from the AC uid graveyard. */
|
|
1180
|
+
const graveyardFp = (acUid) => {
|
|
1181
|
+
const has = db
|
|
1182
|
+
.prepare("SELECT 1 AS x FROM sqlite_master WHERE type = 'table' AND name = ?")
|
|
1183
|
+
.get(AC_UID_GRAVEYARD);
|
|
1184
|
+
if (!has)
|
|
1185
|
+
return null;
|
|
1186
|
+
const r = db
|
|
1187
|
+
.prepare(`SELECT birth_fp AS fp FROM ${AC_UID_GRAVEYARD} WHERE uid = ? ORDER BY seq DESC LIMIT 1`)
|
|
1188
|
+
.get(acUid);
|
|
1189
|
+
return r?.fp ?? null;
|
|
1190
|
+
};
|
|
756
1191
|
const refs = {};
|
|
757
1192
|
for (const [column, target] of refTargets(spec)) {
|
|
758
1193
|
const value = row[column];
|
|
759
|
-
|
|
760
|
-
|
|
1194
|
+
refs[column] = value === null || value === undefined ? null : identityOf(target, 'key', value);
|
|
1195
|
+
}
|
|
1196
|
+
for (const [column, { table: target, from }] of storedUidRefs(spec)) {
|
|
1197
|
+
const recorded = row[column];
|
|
1198
|
+
const key = row[from];
|
|
1199
|
+
if (recorded !== null && recorded !== undefined) {
|
|
1200
|
+
refs[column] = identityOf(target, 'uid', recorded);
|
|
1201
|
+
// A recorded uid whose row this store no longer has keeps its uid, with
|
|
1202
|
+
// the fingerprint the graveyard kept when there is one; the receiver
|
|
1203
|
+
// never matches it without a fingerprint.
|
|
1204
|
+
if (refs[column]?.uid === null) {
|
|
1205
|
+
refs[column] = {
|
|
1206
|
+
uid: String(recorded),
|
|
1207
|
+
birthFp: graveyardFp(String(recorded)),
|
|
1208
|
+
gone: true,
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
else {
|
|
1213
|
+
refs[column] = key === null || key === undefined ? null : identityOf(target, 'key', key);
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
const jsonRefs = {};
|
|
1217
|
+
for (const [column, target] of jsonArrayTargets(spec)) {
|
|
1218
|
+
const raw = row[column];
|
|
1219
|
+
let ids;
|
|
1220
|
+
try {
|
|
1221
|
+
ids = typeof raw === 'string' ? JSON.parse(raw) : null;
|
|
1222
|
+
}
|
|
1223
|
+
catch {
|
|
1224
|
+
ids = null;
|
|
1225
|
+
}
|
|
1226
|
+
if (!Array.isArray(ids)) {
|
|
1227
|
+
values[column] = raw ?? null; // not an id array: sent as is
|
|
761
1228
|
continue;
|
|
762
1229
|
}
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
.
|
|
768
|
-
if (t)
|
|
769
|
-
refs[column] = { uid: t.uid, birthFp: t.fp };
|
|
1230
|
+
// An id whose task this store no longer has is dropped from the array:
|
|
1231
|
+
// there is nothing it could name on the receiver (T12798 review).
|
|
1232
|
+
jsonRefs[column] = ids
|
|
1233
|
+
.map((id) => identityOf(target, 'key', String(id)))
|
|
1234
|
+
.filter((ref) => !ref.gone);
|
|
770
1235
|
}
|
|
771
1236
|
return {
|
|
1237
|
+
version: WIRE_VERSION,
|
|
772
1238
|
table,
|
|
773
1239
|
uid,
|
|
774
1240
|
birthFp: spec.kind === 'minted' ? (row[BIRTH_FP_COLUMN] ?? null) : null,
|
|
775
1241
|
values,
|
|
776
1242
|
refs,
|
|
1243
|
+
...(Object.keys(jsonRefs).length > 0 ? { jsonRefs } : {}),
|
|
777
1244
|
};
|
|
778
1245
|
}
|
|
779
1246
|
/** Stored-uid copies, display aliases, the re-mint record and the uid alias of a moved row. */
|
|
@@ -796,7 +1263,12 @@ function followRow(db, table, key, oldUid, birthFp, newUid, options) {
|
|
|
796
1263
|
.all(table, oldUid, birthFp);
|
|
797
1264
|
for (const a of aliases) {
|
|
798
1265
|
deleteDisplayIdAliasNative(db, a.uid);
|
|
799
|
-
const uid = naturalRowUid('project', DISPLAY_ID_ALIAS_TABLE, [
|
|
1266
|
+
const uid = naturalRowUid('project', DISPLAY_ID_ALIAS_TABLE, [
|
|
1267
|
+
table,
|
|
1268
|
+
a.displayId,
|
|
1269
|
+
newUid,
|
|
1270
|
+
a.reason,
|
|
1271
|
+
]);
|
|
800
1272
|
insertDisplayIdAliasNative(db, {
|
|
801
1273
|
uid,
|
|
802
1274
|
entityTable: table,
|
|
@@ -807,16 +1279,9 @@ function followRow(db, table, key, oldUid, birthFp, newUid, options) {
|
|
|
807
1279
|
origin: a.origin,
|
|
808
1280
|
displacedHlc: a.displacedHlc,
|
|
809
1281
|
createdAt: a.createdAt,
|
|
810
|
-
});
|
|
1282
|
+
}, aliasPrecedes);
|
|
811
1283
|
moved.push({ table: DISPLAY_ID_ALIAS_TABLE, oldUid: a.uid, newUid: uid });
|
|
812
1284
|
}
|
|
813
|
-
if (table === 'tasks_tasks') {
|
|
814
|
-
const remint = readMeta(db, remintKey(oldUid, birthFp));
|
|
815
|
-
if (remint !== undefined) {
|
|
816
|
-
writeRowIdentityMetaNative(db, remintKey(newUid, birthFp), remint);
|
|
817
|
-
deleteRowIdentityMetaNative(db, remintKey(oldUid, birthFp));
|
|
818
|
-
}
|
|
819
|
-
}
|
|
820
1285
|
insertUidAliasNative(db, {
|
|
821
1286
|
uid: naturalRowUid('project', UID_ALIAS_TABLE, [table, oldUid, birthFp]),
|
|
822
1287
|
entityTable: table,
|
|
@@ -963,16 +1428,26 @@ function rekeyDerive(db, table, oldUid, birthFp, newUid, options) {
|
|
|
963
1428
|
* @param db - Connection on the project `cleo.db`.
|
|
964
1429
|
* @param table - Minted table of the row.
|
|
965
1430
|
* @param uid - The colliding uid.
|
|
966
|
-
* @param
|
|
1431
|
+
* @param collision - The loser's and the winner's birth fingerprints (the
|
|
1432
|
+
* winner must be the smaller; T12801).
|
|
967
1433
|
* @param options - The authority and the HLC of the re-key.
|
|
968
1434
|
* @returns The receipt to publish, with its cascade and natural uids.
|
|
969
1435
|
* @throws CleoError when this replica has no row (uid, loserBirthFp), or the
|
|
970
1436
|
* fingerprint is not the loser's.
|
|
971
1437
|
*/
|
|
972
|
-
export function rekeyRowUid(db, table, uid,
|
|
1438
|
+
export function rekeyRowUid(db, table, uid, collision, options = {}) {
|
|
973
1439
|
if (specOf(table).kind !== 'minted') {
|
|
974
1440
|
throw new Error(`rekey: ${table} is not a declared minted table`);
|
|
975
1441
|
}
|
|
1442
|
+
const { loserBirthFp, winnerBirthFp } = collision;
|
|
1443
|
+
// The loser is the greater fingerprint of the pair; the caller names both,
|
|
1444
|
+
// so a swapped or stale pair is refused rather than re-keying the winner.
|
|
1445
|
+
if (!(winnerBirthFp < loserBirthFp)) {
|
|
1446
|
+
throw new CleoError(ExitCode.VALIDATION_ERROR, `rekey: ${loserBirthFp} is not the loser of the ${table} uid collision on ${uid} (the loser is the greater fingerprint; winner ${winnerBirthFp})`, {
|
|
1447
|
+
fix: 'Re-key the row with the greater birth fingerprint; the smaller one keeps the uid.',
|
|
1448
|
+
details: { field: 'loserBirthFp', actual: loserBirthFp, expected: winnerBirthFp },
|
|
1449
|
+
});
|
|
1450
|
+
}
|
|
976
1451
|
const rivals = [
|
|
977
1452
|
...db
|
|
978
1453
|
.prepare(`SELECT ${q(BIRTH_FP_COLUMN)} AS fp FROM main.${q(table)} WHERE ${q(UID_COLUMN)} = ?`)
|
|
@@ -981,7 +1456,7 @@ export function rekeyRowUid(db, table, uid, loserBirthFp, options = {}) {
|
|
|
981
1456
|
.prepare(`SELECT birth_fp AS fp FROM ${QUARANTINE_TABLE} WHERE entity_table = ? AND uid = ?`)
|
|
982
1457
|
.all(table, uid).map((r) => r.fp),
|
|
983
1458
|
].filter((fp) => fp !== null && fp !== loserBirthFp);
|
|
984
|
-
const greater = rivals.find((fp) => fp > loserBirthFp);
|
|
1459
|
+
const greater = rivals.find((fp) => fp > loserBirthFp || fp !== winnerBirthFp);
|
|
985
1460
|
if (greater !== undefined) {
|
|
986
1461
|
throw new CleoError(ExitCode.VALIDATION_ERROR, `rekey: ${loserBirthFp} is not the loser of the ${table} uid collision on ${uid}`, {
|
|
987
1462
|
fix: 'Re-key the row with the greater birth fingerprint; the smaller one keeps the uid.',
|
|
@@ -997,7 +1472,7 @@ export function rekeyRowUid(db, table, uid, loserBirthFp, options = {}) {
|
|
|
997
1472
|
registerRowUidFunction(db, 'project');
|
|
998
1473
|
return inSavepoint(db, 'rekey', () => {
|
|
999
1474
|
const receipt = rekeyDerive(db, table, uid, loserBirthFp, mintRowUid(), options);
|
|
1000
|
-
releaseHeldRows(db);
|
|
1475
|
+
releaseHeldRows(db, rekeyTriggers(receipt));
|
|
1001
1476
|
return receipt;
|
|
1002
1477
|
});
|
|
1003
1478
|
}
|
|
@@ -1057,7 +1532,7 @@ export function applyRekey(db, receipt) {
|
|
|
1057
1532
|
markRowIdentityShared(db, 'receive');
|
|
1058
1533
|
const rows = [];
|
|
1059
1534
|
applyEntry(db, receipt, rows);
|
|
1060
|
-
return { rows, released: releaseHeldRows(db) };
|
|
1535
|
+
return { rows, released: releaseHeldRows(db, rekeyTriggers(receipt)) };
|
|
1061
1536
|
});
|
|
1062
1537
|
}
|
|
1063
1538
|
//# sourceMappingURL=display-id-alias.js.map
|