@sigrun/knowledge 0.0.0-stage → 0.1.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/CHANGELOG.md +82 -0
- package/LICENSE +21 -0
- package/README.md +83 -2
- package/dist/apply.d.ts +56 -0
- package/dist/apply.js +554 -0
- package/dist/audience.d.ts +12 -0
- package/dist/audience.js +26 -0
- package/dist/card.d.ts +15 -0
- package/dist/card.js +83 -0
- package/dist/config.d.ts +15 -0
- package/dist/config.js +44 -0
- package/dist/db.d.ts +18 -0
- package/dist/db.js +156 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +52 -0
- package/dist/maintain.d.ts +21 -0
- package/dist/maintain.js +585 -0
- package/dist/mirror.d.ts +41 -0
- package/dist/mirror.js +59 -0
- package/dist/prompts.d.ts +6 -0
- package/dist/prompts.js +16 -0
- package/dist/research.d.ts +46 -0
- package/dist/research.js +81 -0
- package/dist/schema.d.ts +44 -0
- package/dist/schema.js +145 -0
- package/dist/target.d.ts +23 -0
- package/dist/target.js +29 -0
- package/dist/tools.d.ts +45 -0
- package/dist/tools.js +357 -0
- package/dist/undo.d.ts +16 -0
- package/dist/undo.js +335 -0
- package/package.json +57 -5
package/dist/apply.js
ADDED
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { effectiveFields, TYPE_NAME_PATTERN, validateValue, } from "./schema.js";
|
|
3
|
+
const MAX_TYPES_PER_RECORD = 10;
|
|
4
|
+
const MAX_ENTITIES_PER_RECORD = 50;
|
|
5
|
+
const ENTITY_ROLES = ["candidate", "shortlisted", "rejected", "chosen"];
|
|
6
|
+
/** Marker thrown inside the transaction to roll back on validation errors. */
|
|
7
|
+
class RecordInvalid extends Error {
|
|
8
|
+
}
|
|
9
|
+
/** Normalize an identity key value for matching within a type family. */
|
|
10
|
+
export function normalizeKey(key, value) {
|
|
11
|
+
if (/phone|tel|fax|mobile/i.test(key)) {
|
|
12
|
+
return value.replace(/\D/g, "");
|
|
13
|
+
}
|
|
14
|
+
if (/^https?:\/\//i.test(value) || /(^|\s)www\./i.test(value)) {
|
|
15
|
+
return value
|
|
16
|
+
.trim()
|
|
17
|
+
.replace(/^https?:\/\//i, "")
|
|
18
|
+
.replace(/^www\./i, "")
|
|
19
|
+
.replace(/\/+$/, "")
|
|
20
|
+
.toLowerCase();
|
|
21
|
+
}
|
|
22
|
+
return value.toLowerCase().trim().replace(/\s+/g, " ");
|
|
23
|
+
}
|
|
24
|
+
function loadTypes(db) {
|
|
25
|
+
const rows = db
|
|
26
|
+
.raw()
|
|
27
|
+
.prepare("SELECT id, name, description, parentId, fields, version, archived FROM types WHERE archived = 0")
|
|
28
|
+
.all();
|
|
29
|
+
const map = new Map();
|
|
30
|
+
for (const row of rows) {
|
|
31
|
+
map.set(row.name, {
|
|
32
|
+
...row,
|
|
33
|
+
fields: JSON.parse(row.fields),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return map;
|
|
37
|
+
}
|
|
38
|
+
/** The topmost ancestor type id: one identity family per hierarchy. */
|
|
39
|
+
function familyRootId(typesById, typeId) {
|
|
40
|
+
let current = typeId;
|
|
41
|
+
for (;;) {
|
|
42
|
+
const type = typesById.get(current);
|
|
43
|
+
if (type === undefined || type.parentId === null)
|
|
44
|
+
return current;
|
|
45
|
+
current = type.parentId;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function writeVersion(db, changeId, table, rowId, before, after, at) {
|
|
49
|
+
db.raw()
|
|
50
|
+
.prepare('INSERT INTO versions (changeId, "table", rowId, before, after, at) VALUES (?, ?, ?, ?, ?, ?)')
|
|
51
|
+
.run(changeId, table, rowId, before === undefined || before === null
|
|
52
|
+
? null
|
|
53
|
+
: JSON.stringify(before), after === undefined || after === null
|
|
54
|
+
? null
|
|
55
|
+
: JSON.stringify(after), at);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Apply one knowledge record atomically. Any validation error rolls the
|
|
59
|
+
* whole record back and is reported in `errors`. Replaying an already
|
|
60
|
+
* applied `callId` returns the stored result unchanged.
|
|
61
|
+
*/
|
|
62
|
+
export function applyRecord(db, input, ctx) {
|
|
63
|
+
const replayed = db
|
|
64
|
+
.raw()
|
|
65
|
+
.prepare("SELECT result FROM applied WHERE callId = ?")
|
|
66
|
+
.get(ctx.callId);
|
|
67
|
+
if (replayed !== undefined) {
|
|
68
|
+
return JSON.parse(replayed.result);
|
|
69
|
+
}
|
|
70
|
+
const errors = [];
|
|
71
|
+
let result;
|
|
72
|
+
try {
|
|
73
|
+
db.transaction(() => {
|
|
74
|
+
const applied = applyValidating(db, input, ctx, errors);
|
|
75
|
+
if (applied === undefined)
|
|
76
|
+
throw new RecordInvalid();
|
|
77
|
+
result = applied;
|
|
78
|
+
db.raw()
|
|
79
|
+
.prepare("INSERT INTO applied (callId, changeId, result) VALUES (?, ?, ?)")
|
|
80
|
+
.run(ctx.callId, result.changeId, JSON.stringify(result));
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
if (error instanceof RecordInvalid) {
|
|
85
|
+
return { changeId: "", summary: "", touched: [], errors };
|
|
86
|
+
}
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
if (result === undefined) {
|
|
90
|
+
throw new Error("applyRecord: transaction finished without a result");
|
|
91
|
+
}
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
function applyValidating(db, input, ctx, errors) {
|
|
95
|
+
if (input.types !== undefined && input.types.length > MAX_TYPES_PER_RECORD) {
|
|
96
|
+
errors.push(`record declares ${input.types.length} types (max ${MAX_TYPES_PER_RECORD})`);
|
|
97
|
+
}
|
|
98
|
+
if (input.entities.length > MAX_ENTITIES_PER_RECORD) {
|
|
99
|
+
errors.push(`record carries ${input.entities.length} entities (max ${MAX_ENTITIES_PER_RECORD})`);
|
|
100
|
+
}
|
|
101
|
+
if (errors.length > 0)
|
|
102
|
+
return undefined;
|
|
103
|
+
const changeId = randomUUID();
|
|
104
|
+
const typesByName = loadTypes(db);
|
|
105
|
+
let typesById = new Map();
|
|
106
|
+
for (const type of typesByName.values())
|
|
107
|
+
typesById.set(type.id, type);
|
|
108
|
+
// Types: create, or extend by adding fields (never remove or retype).
|
|
109
|
+
// A parent may be declared later in the same record, so resolve the
|
|
110
|
+
// hierarchy in a fixpoint pass over the unresolved defs.
|
|
111
|
+
const typeErrors = [];
|
|
112
|
+
for (const def of input.types ?? []) {
|
|
113
|
+
validateTypeDef(def, typeErrors);
|
|
114
|
+
if (typeErrors.length > 0)
|
|
115
|
+
break;
|
|
116
|
+
const seen = new Set();
|
|
117
|
+
const declared = new Map((input.types ?? []).map((d) => [d.name, d]));
|
|
118
|
+
let cursor = def;
|
|
119
|
+
while (cursor?.parent !== undefined) {
|
|
120
|
+
if (seen.has(cursor.name)) {
|
|
121
|
+
typeErrors.push(`type "${def.name}" has a parent cycle`);
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
seen.add(cursor.name);
|
|
125
|
+
cursor = declared.get(cursor.parent);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (typeErrors.length > 0) {
|
|
129
|
+
errors.push(...typeErrors);
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
// Create/extend in a fixpoint pass so a parent declared later in the
|
|
133
|
+
// same record resolves before its children are created.
|
|
134
|
+
const pending = new Map((input.types ?? []).map((def) => [def.name, def]));
|
|
135
|
+
for (;;) {
|
|
136
|
+
let progressed = false;
|
|
137
|
+
for (const def of pending.values()) {
|
|
138
|
+
if (pending.has(def.parent ?? ""))
|
|
139
|
+
continue;
|
|
140
|
+
applyTypeDef(db, def, typesByName, typesById, errors, changeId, ctx.now);
|
|
141
|
+
pending.delete(def.name);
|
|
142
|
+
progressed = true;
|
|
143
|
+
if (errors.length > 0)
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
if (!progressed || errors.length > 0)
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
for (const def of pending.values()) {
|
|
150
|
+
errors.push(`type "${def.name}" has unknown parent "${def.parent}"`);
|
|
151
|
+
}
|
|
152
|
+
if (errors.length > 0)
|
|
153
|
+
return undefined;
|
|
154
|
+
function applyTypeDef(db, def, typesByName, typesById, errors, changeId, now) {
|
|
155
|
+
const existing = typesByName.get(def.name);
|
|
156
|
+
if (existing === undefined) {
|
|
157
|
+
const parentId = def.parent === undefined
|
|
158
|
+
? null
|
|
159
|
+
: (typesByName.get(def.parent)?.id ?? null);
|
|
160
|
+
const inserted = db
|
|
161
|
+
.raw()
|
|
162
|
+
.prepare("INSERT INTO types (name, description, parentId, fields, version) VALUES (?, ?, ?, ?, 1)")
|
|
163
|
+
.run(def.name, def.description, parentId, JSON.stringify(def.fields));
|
|
164
|
+
const stored = {
|
|
165
|
+
id: Number(inserted.lastInsertRowid),
|
|
166
|
+
name: def.name,
|
|
167
|
+
description: def.description,
|
|
168
|
+
parentId,
|
|
169
|
+
fields: def.fields,
|
|
170
|
+
version: 1,
|
|
171
|
+
archived: 0,
|
|
172
|
+
};
|
|
173
|
+
typesByName.set(def.name, stored);
|
|
174
|
+
typesById.set(stored.id, stored);
|
|
175
|
+
writeVersion(db, changeId, "types", String(stored.id), null, stored, now);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
// Extending an existing type: every stored field must be present
|
|
179
|
+
// with the same type; new fields append.
|
|
180
|
+
const merged = [...existing.fields];
|
|
181
|
+
const byName = new Map(merged.map((field) => [field.name, field]));
|
|
182
|
+
for (const field of def.fields) {
|
|
183
|
+
const stored = byName.get(field.name);
|
|
184
|
+
if (stored === undefined) {
|
|
185
|
+
merged.push(field);
|
|
186
|
+
}
|
|
187
|
+
else if (stored.type !== field.type) {
|
|
188
|
+
errors.push(`type "${def.name}" retypes field "${field.name}" (${stored.type} -> ${field.type})`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
for (const stored of existing.fields) {
|
|
192
|
+
if (!def.fields.some((field) => field.name === stored.name)) {
|
|
193
|
+
errors.push(`type "${def.name}" update drops field "${stored.name}"`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (errors.length > 0)
|
|
197
|
+
return;
|
|
198
|
+
if (merged.length !== existing.fields.length) {
|
|
199
|
+
const next = {
|
|
200
|
+
...existing,
|
|
201
|
+
fields: merged,
|
|
202
|
+
version: existing.version + 1,
|
|
203
|
+
};
|
|
204
|
+
writeVersion(db, changeId, "types", String(existing.id), existing, next, now);
|
|
205
|
+
db.raw()
|
|
206
|
+
.prepare("UPDATE types SET fields = ?, version = version + 1 WHERE id = ?")
|
|
207
|
+
.run(JSON.stringify(merged), existing.id);
|
|
208
|
+
existing.fields = merged;
|
|
209
|
+
existing.version += 1;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
typesById = new Map();
|
|
213
|
+
for (const type of typesByName.values())
|
|
214
|
+
typesById.set(type.id, type);
|
|
215
|
+
// Inquiry: match by question, else create.
|
|
216
|
+
let inquiryId;
|
|
217
|
+
if (input.inquiry !== undefined) {
|
|
218
|
+
inquiryId = applyInquiry(db, input.inquiry, ctx, changeId);
|
|
219
|
+
}
|
|
220
|
+
const touched = [];
|
|
221
|
+
const touchedTypeNames = [];
|
|
222
|
+
for (const entityInput of input.entities) {
|
|
223
|
+
const type = typesByName.get(entityInput.type);
|
|
224
|
+
if (type === undefined) {
|
|
225
|
+
errors.push(`entity "${entityInput.name}" has unknown type "${entityInput.type}"`);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (entityInput.role !== undefined &&
|
|
229
|
+
!ENTITY_ROLES.includes(entityInput.role)) {
|
|
230
|
+
errors.push(`entity "${entityInput.name}" has invalid role "${entityInput.role}"`);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (entityInput.role !== undefined && inquiryId === undefined) {
|
|
234
|
+
errors.push(`entity "${entityInput.name}" carries a role but the record has no inquiry`);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
for (const [fieldName, field] of Object.entries(entityInput.fields)) {
|
|
238
|
+
// Inherited fields count: a parent's def validates a child's value.
|
|
239
|
+
const effective = effectiveFields(typesById, type.id);
|
|
240
|
+
const def = effective.find((candidate) => candidate.name === fieldName);
|
|
241
|
+
if (def === undefined) {
|
|
242
|
+
errors.push(`entity "${entityInput.name}" field "${fieldName}" is not declared on type "${type.name}"`);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
const invalid = validateValue(def, field.value);
|
|
246
|
+
if (invalid !== undefined) {
|
|
247
|
+
errors.push(`entity "${entityInput.name}" field "${fieldName}": ${invalid}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (errors.length > 0)
|
|
251
|
+
continue;
|
|
252
|
+
const entityId = applyEntity(db, entityInput, type, typesById, changeId, ctx.now, errors);
|
|
253
|
+
if (entityId === undefined)
|
|
254
|
+
continue;
|
|
255
|
+
touched.push(entityId);
|
|
256
|
+
if (!touchedTypeNames.includes(type.name))
|
|
257
|
+
touchedTypeNames.push(type.name);
|
|
258
|
+
if (entityInput.role !== undefined && inquiryId !== undefined) {
|
|
259
|
+
applyRole(db, inquiryId, entityId, entityInput.role, entityInput.reason, changeId, ctx.now);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (errors.length > 0)
|
|
263
|
+
return undefined;
|
|
264
|
+
const subject = input.inquiry?.question ?? "facts";
|
|
265
|
+
const typeSummary = touchedTypeNames.map((name) => `${name}s`).join(", ");
|
|
266
|
+
return {
|
|
267
|
+
changeId,
|
|
268
|
+
summary: `recorded ${subject}: ${touched.length} ${typeSummary}`,
|
|
269
|
+
touched,
|
|
270
|
+
errors: [],
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
function validateTypeDef(def, errors) {
|
|
274
|
+
if (!TYPE_NAME_PATTERN.test(def.name)) {
|
|
275
|
+
errors.push(`type name "${def.name}" is not a lowercase singular phrase`);
|
|
276
|
+
}
|
|
277
|
+
if (typeof def.description !== "string" || def.description === "") {
|
|
278
|
+
errors.push(`type "${def.name}" needs a description`);
|
|
279
|
+
}
|
|
280
|
+
if (def.parent !== undefined &&
|
|
281
|
+
(def.name === def.parent || !TYPE_NAME_PATTERN.test(def.parent))) {
|
|
282
|
+
errors.push(`type "${def.name}" has an invalid parent "${def.parent}"`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
function applyInquiry(db, inquiry, ctx, changeId) {
|
|
286
|
+
const existing = db
|
|
287
|
+
.raw()
|
|
288
|
+
.prepare("SELECT id, conversations, outcome, archived FROM inquiries WHERE question = ?")
|
|
289
|
+
.get(inquiry.question);
|
|
290
|
+
if (existing === undefined) {
|
|
291
|
+
const conversations = ctx.conversationId === undefined ? [] : [ctx.conversationId];
|
|
292
|
+
const inserted = db
|
|
293
|
+
.raw()
|
|
294
|
+
.prepare("INSERT INTO inquiries (question, why, startedAt, touchedAt, conversations, outcome) VALUES (?, ?, ?, ?, ?, ?)")
|
|
295
|
+
.run(inquiry.question, inquiry.why ?? null, ctx.now, ctx.now, JSON.stringify(conversations), inquiry.outcome === undefined
|
|
296
|
+
? null
|
|
297
|
+
: JSON.stringify(inquiry.outcome));
|
|
298
|
+
const stored = {
|
|
299
|
+
id: Number(inserted.lastInsertRowid),
|
|
300
|
+
question: inquiry.question,
|
|
301
|
+
why: inquiry.why ?? null,
|
|
302
|
+
startedAt: ctx.now,
|
|
303
|
+
touchedAt: ctx.now,
|
|
304
|
+
conversations: JSON.stringify(conversations),
|
|
305
|
+
outcome: inquiry.outcome === undefined
|
|
306
|
+
? null
|
|
307
|
+
: JSON.stringify(inquiry.outcome),
|
|
308
|
+
archived: 0,
|
|
309
|
+
};
|
|
310
|
+
writeVersion(db, changeId, "inquiries", String(stored.id), null, stored, ctx.now);
|
|
311
|
+
return stored.id;
|
|
312
|
+
}
|
|
313
|
+
if (existing.archived !== 0) {
|
|
314
|
+
// An archived inquiry matching this question is revived by the
|
|
315
|
+
// record, not duplicated (undo archives; a re-apply unarchives).
|
|
316
|
+
const revived = { ...existing, archived: 0, touchedAt: ctx.now };
|
|
317
|
+
writeVersion(db, changeId, "inquiries", String(existing.id), existing, revived, ctx.now);
|
|
318
|
+
db.raw()
|
|
319
|
+
.prepare("UPDATE inquiries SET archived = 0, touchedAt = ? WHERE id = ?")
|
|
320
|
+
.run(ctx.now, existing.id);
|
|
321
|
+
existing.archived = 0;
|
|
322
|
+
}
|
|
323
|
+
// Existing inquiry: only record a version when something changed.
|
|
324
|
+
const conversations = JSON.parse(existing.conversations);
|
|
325
|
+
const conversationAdded = ctx.conversationId !== undefined &&
|
|
326
|
+
!conversations.includes(ctx.conversationId);
|
|
327
|
+
const outcomeChanged = inquiry.outcome !== undefined &&
|
|
328
|
+
existing.outcome !== JSON.stringify(inquiry.outcome);
|
|
329
|
+
if (conversationAdded || outcomeChanged) {
|
|
330
|
+
const next = {
|
|
331
|
+
conversations: conversationAdded
|
|
332
|
+
? [...conversations, ctx.conversationId]
|
|
333
|
+
: conversations,
|
|
334
|
+
outcome: outcomeChanged ? inquiry.outcome : existing.outcome,
|
|
335
|
+
};
|
|
336
|
+
writeVersion(db, changeId, "inquiries", String(existing.id), {
|
|
337
|
+
conversations: existing.conversations,
|
|
338
|
+
outcome: existing.outcome,
|
|
339
|
+
touchedAt: ctx.now,
|
|
340
|
+
}, {
|
|
341
|
+
conversations: JSON.stringify(next.conversations),
|
|
342
|
+
outcome: typeof next.outcome === "string"
|
|
343
|
+
? next.outcome
|
|
344
|
+
: next.outcome === undefined || next.outcome === null
|
|
345
|
+
? null
|
|
346
|
+
: JSON.stringify(next.outcome),
|
|
347
|
+
touchedAt: ctx.now,
|
|
348
|
+
}, ctx.now);
|
|
349
|
+
db.raw()
|
|
350
|
+
.prepare("UPDATE inquiries SET conversations = ?, outcome = ?, touchedAt = ? WHERE id = ?")
|
|
351
|
+
.run(JSON.stringify(next.conversations), typeof next.outcome === "string"
|
|
352
|
+
? next.outcome
|
|
353
|
+
: next.outcome === undefined || next.outcome === null
|
|
354
|
+
? null
|
|
355
|
+
: JSON.stringify(next.outcome), ctx.now, existing.id);
|
|
356
|
+
}
|
|
357
|
+
return existing.id;
|
|
358
|
+
}
|
|
359
|
+
function applyRole(db, inquiryId, entityId, role, reason, changeId, now) {
|
|
360
|
+
const existing = db
|
|
361
|
+
.raw()
|
|
362
|
+
.prepare("SELECT id, role, reason FROM roles WHERE inquiryId = ? AND entityId = ?")
|
|
363
|
+
.get(inquiryId, entityId);
|
|
364
|
+
if (existing === undefined) {
|
|
365
|
+
const inserted = db
|
|
366
|
+
.raw()
|
|
367
|
+
.prepare("INSERT INTO roles (inquiryId, entityId, role, reason) VALUES (?, ?, ?, ?)")
|
|
368
|
+
.run(inquiryId, entityId, role, reason ?? null);
|
|
369
|
+
writeVersion(db, changeId, "roles", String(inserted.lastInsertRowid), null, { id: Number(inserted.lastInsertRowid), inquiryId, entityId, role, reason: reason ?? null }, now);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (existing.role === role && existing.reason === (reason ?? null))
|
|
373
|
+
return;
|
|
374
|
+
writeVersion(db, changeId, "roles", String(existing.id), existing, { ...existing, role, reason: reason ?? null }, now);
|
|
375
|
+
db.raw()
|
|
376
|
+
.prepare("UPDATE roles SET role = ?, reason = ? WHERE id = ?")
|
|
377
|
+
.run(role, reason ?? null, existing.id);
|
|
378
|
+
}
|
|
379
|
+
function applyEntity(db, input, type, typesById, changeId, now, errors) {
|
|
380
|
+
const rootId = familyRootId(typesById, type.id);
|
|
381
|
+
// Match: explicit id, else any identity key within the type family.
|
|
382
|
+
// Name similarity never matches.
|
|
383
|
+
let existing;
|
|
384
|
+
if (input.id !== undefined) {
|
|
385
|
+
existing = db
|
|
386
|
+
.raw()
|
|
387
|
+
.prepare("SELECT id, typeId, name, keys, fields, attributes, cls, indexedAt, archived FROM entities WHERE id = ? AND archived = 0")
|
|
388
|
+
.get(input.id);
|
|
389
|
+
if (existing === undefined) {
|
|
390
|
+
errors.push(`entity "${input.name}" references unknown id "${input.id}"`);
|
|
391
|
+
return undefined;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
else {
|
|
395
|
+
for (const [key, value] of Object.entries(input.keys)) {
|
|
396
|
+
const normalized = normalizeKey(key, value);
|
|
397
|
+
if (normalized === "")
|
|
398
|
+
continue;
|
|
399
|
+
// Deliberate: the key NAME and the normalized value must both
|
|
400
|
+
// match. `phone:555-0100` and `tel:555-0100` are different
|
|
401
|
+
// claims and never merge an entity on their own.
|
|
402
|
+
const match = db
|
|
403
|
+
.raw()
|
|
404
|
+
.prepare(`SELECT e.id, e.typeId, e.name, e.keys, e.fields, e.attributes, e.cls, e.indexedAt, e.archived
|
|
405
|
+
FROM entity_keys k JOIN entities e ON e.id = k.entityId
|
|
406
|
+
WHERE k.typeFamilyRoot = ? AND k.key = ? AND k.normalized = ? AND e.archived = 0`)
|
|
407
|
+
.get(rootId, key, normalized);
|
|
408
|
+
if (match !== undefined) {
|
|
409
|
+
existing = match;
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (existing === undefined) {
|
|
415
|
+
const id = randomUUID();
|
|
416
|
+
const keyNames = Object.keys(input.keys);
|
|
417
|
+
db.raw()
|
|
418
|
+
.prepare("INSERT INTO entities (id, typeId, name, keys, fields, attributes, indexedAt) VALUES (?, ?, ?, ?, ?, ?, NULL)")
|
|
419
|
+
.run(id, type.id, input.name, JSON.stringify(keyNames), JSON.stringify(input.fields), JSON.stringify(input.attributes ?? {}));
|
|
420
|
+
for (const [key, value] of Object.entries(input.keys)) {
|
|
421
|
+
insertKey(db, id, rootId, key, value);
|
|
422
|
+
}
|
|
423
|
+
const stored = {
|
|
424
|
+
id,
|
|
425
|
+
typeId: type.id,
|
|
426
|
+
name: input.name,
|
|
427
|
+
keys: JSON.stringify(keyNames),
|
|
428
|
+
fields: JSON.stringify(input.fields),
|
|
429
|
+
attributes: JSON.stringify(input.attributes ?? {}),
|
|
430
|
+
cls: "normal",
|
|
431
|
+
indexedAt: null,
|
|
432
|
+
archived: 0,
|
|
433
|
+
};
|
|
434
|
+
writeVersion(db, changeId, "entities", id, null, stored, now);
|
|
435
|
+
return id;
|
|
436
|
+
}
|
|
437
|
+
// Update: merge fields, add any new identity keys.
|
|
438
|
+
const storedFields = JSON.parse(existing.fields);
|
|
439
|
+
const nextFields = { ...storedFields, ...input.fields };
|
|
440
|
+
const storedAttributes = JSON.parse(existing.attributes);
|
|
441
|
+
const nextAttributes = { ...storedAttributes, ...(input.attributes ?? {}) };
|
|
442
|
+
const storedKeyNames = new Set(JSON.parse(existing.keys));
|
|
443
|
+
for (const [key, value] of Object.entries(input.keys)) {
|
|
444
|
+
storedKeyNames.add(key);
|
|
445
|
+
const normalized = normalizeKey(key, value);
|
|
446
|
+
const present = db
|
|
447
|
+
.raw()
|
|
448
|
+
.prepare("SELECT id FROM entity_keys WHERE entityId = ? AND key = ? AND normalized = ?")
|
|
449
|
+
.get(existing.id, key, normalized);
|
|
450
|
+
if (present === undefined) {
|
|
451
|
+
const row = insertKey(db, existing.id, rootId, key, value);
|
|
452
|
+
writeVersion(db, changeId, "entity_keys", String(row.id), null, row, now);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
const next = {
|
|
456
|
+
...existing,
|
|
457
|
+
typeId: type.id,
|
|
458
|
+
name: input.name,
|
|
459
|
+
keys: JSON.stringify([...storedKeyNames]),
|
|
460
|
+
fields: JSON.stringify(nextFields),
|
|
461
|
+
attributes: JSON.stringify(nextAttributes),
|
|
462
|
+
// indexedAt is the mirror's stamp (recall.index succeeded at that
|
|
463
|
+
// instant): a new version leaves it BEHIND so the next idle pass
|
|
464
|
+
// retries the index. Never stamped here.
|
|
465
|
+
indexedAt: existing.indexedAt,
|
|
466
|
+
};
|
|
467
|
+
const changed = next.fields !== existing.fields ||
|
|
468
|
+
next.attributes !== existing.attributes ||
|
|
469
|
+
next.keys !== existing.keys ||
|
|
470
|
+
next.name !== existing.name ||
|
|
471
|
+
next.typeId !== existing.typeId;
|
|
472
|
+
if (changed) {
|
|
473
|
+
writeVersion(db, changeId, "entities", existing.id, existing, next, now);
|
|
474
|
+
db.raw()
|
|
475
|
+
.prepare("UPDATE entities SET typeId = ?, name = ?, keys = ?, fields = ?, attributes = ?, indexedAt = ? WHERE id = ?")
|
|
476
|
+
.run(next.typeId, next.name, next.keys, next.fields, next.attributes, next.indexedAt, existing.id);
|
|
477
|
+
}
|
|
478
|
+
return existing.id;
|
|
479
|
+
}
|
|
480
|
+
function insertKey(db, entityId, rootId, key, value) {
|
|
481
|
+
const normalized = normalizeKey(key, value);
|
|
482
|
+
const inserted = db
|
|
483
|
+
.raw()
|
|
484
|
+
.prepare("INSERT INTO entity_keys (entityId, typeFamilyRoot, key, normalized) VALUES (?, ?, ?, ?)")
|
|
485
|
+
.run(entityId, rootId, key, normalized);
|
|
486
|
+
return {
|
|
487
|
+
id: Number(inserted.lastInsertRowid),
|
|
488
|
+
entityId,
|
|
489
|
+
typeFamilyRoot: rootId,
|
|
490
|
+
key,
|
|
491
|
+
normalized,
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Move entities to a different type: field values the new type does not
|
|
496
|
+
* declare move into attributes, and identity keys re-root at the new
|
|
497
|
+
* type's family.
|
|
498
|
+
*/
|
|
499
|
+
export function retypeEntities(db, entityIds, toType, changeId, now) {
|
|
500
|
+
db.transaction(() => {
|
|
501
|
+
const typesByName = loadTypes(db);
|
|
502
|
+
const target = typesByName.get(toType);
|
|
503
|
+
if (target === undefined) {
|
|
504
|
+
throw new Error(`retypeEntities: unknown type "${toType}"`);
|
|
505
|
+
}
|
|
506
|
+
const typesById = new Map();
|
|
507
|
+
for (const type of typesByName.values())
|
|
508
|
+
typesById.set(type.id, type);
|
|
509
|
+
const rootId = familyRootId(typesById, target.id);
|
|
510
|
+
const declared = new Set(target.fields.map((field) => field.name));
|
|
511
|
+
for (const entityId of entityIds) {
|
|
512
|
+
const entity = db
|
|
513
|
+
.raw()
|
|
514
|
+
.prepare("SELECT id, typeId, name, keys, fields, attributes, cls, indexedAt, archived FROM entities WHERE id = ?")
|
|
515
|
+
.get(entityId);
|
|
516
|
+
if (entity === undefined)
|
|
517
|
+
continue;
|
|
518
|
+
const fields = JSON.parse(entity.fields);
|
|
519
|
+
const attributes = JSON.parse(entity.attributes);
|
|
520
|
+
const kept = {};
|
|
521
|
+
for (const [name, field] of Object.entries(fields)) {
|
|
522
|
+
if (declared.has(name)) {
|
|
523
|
+
kept[name] = field;
|
|
524
|
+
}
|
|
525
|
+
else {
|
|
526
|
+
attributes[name] = field;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
const next = {
|
|
530
|
+
...entity,
|
|
531
|
+
typeId: target.id,
|
|
532
|
+
fields: JSON.stringify(kept),
|
|
533
|
+
attributes: JSON.stringify(attributes),
|
|
534
|
+
// re-typing leaves indexedAt behind: the next idle pass re-indexes.
|
|
535
|
+
indexedAt: entity.indexedAt,
|
|
536
|
+
};
|
|
537
|
+
writeVersion(db, changeId, "entities", entity.id, entity, next, now);
|
|
538
|
+
db.raw()
|
|
539
|
+
.prepare("UPDATE entities SET typeId = ?, fields = ?, attributes = ?, indexedAt = ? WHERE id = ?")
|
|
540
|
+
.run(next.typeId, next.fields, next.attributes, next.indexedAt, entity.id);
|
|
541
|
+
// The re-root is versioned so undo restores the key family.
|
|
542
|
+
const keyRows = db
|
|
543
|
+
.raw()
|
|
544
|
+
.prepare("SELECT id, entityId, typeFamilyRoot, key, normalized FROM entity_keys WHERE entityId = ?")
|
|
545
|
+
.all(entity.id);
|
|
546
|
+
for (const keyRow of keyRows) {
|
|
547
|
+
writeVersion(db, changeId, "entity_keys", String(keyRow.id), keyRow, { ...keyRow, typeFamilyRoot: rootId }, now);
|
|
548
|
+
}
|
|
549
|
+
db.raw()
|
|
550
|
+
.prepare("UPDATE entity_keys SET typeFamilyRoot = ? WHERE entityId = ?")
|
|
551
|
+
.run(rootId, entity.id);
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type Sharing } from "@sigrun/shared/access";
|
|
2
|
+
/**
|
|
3
|
+
* The audience rule (a private copy of recall's src/audience.ts, kept in
|
|
4
|
+
* lockstep): whether an item from `item` may surface to every ordinary
|
|
5
|
+
* reader of `current`. Superuser access is deliberately not part of this
|
|
6
|
+
* decision.
|
|
7
|
+
*/
|
|
8
|
+
export interface ConversationAccess {
|
|
9
|
+
readonly owner?: string;
|
|
10
|
+
readonly sharing?: Sharing;
|
|
11
|
+
}
|
|
12
|
+
export declare function audienceCovers(current: ConversationAccess, item: ConversationAccess): boolean;
|
package/dist/audience.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { accessOf } from "@sigrun/shared/access";
|
|
2
|
+
export function audienceCovers(current, item) {
|
|
3
|
+
// Ownerless items are readable by everyone.
|
|
4
|
+
if (item.owner === undefined)
|
|
5
|
+
return true;
|
|
6
|
+
if (current.sharing?.scope === "people") {
|
|
7
|
+
const users = new Set(current.sharing.users);
|
|
8
|
+
if (current.owner !== undefined)
|
|
9
|
+
users.add(current.owner);
|
|
10
|
+
for (const user of users) {
|
|
11
|
+
if (accessOf({ user }, item) === "none")
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
return (current.sharing.groups ?? []).every((group) => item.sharing?.scope === "household" ||
|
|
15
|
+
(item.sharing?.scope === "people" &&
|
|
16
|
+
item.sharing.groups?.includes(group) === true));
|
|
17
|
+
}
|
|
18
|
+
if (current.sharing?.scope === "household") {
|
|
19
|
+
return item.sharing?.scope === "household";
|
|
20
|
+
}
|
|
21
|
+
if (current.owner === undefined) {
|
|
22
|
+
return item.sharing?.scope === "household";
|
|
23
|
+
}
|
|
24
|
+
// Private, including an absent sharing grant: only the owner reads current.
|
|
25
|
+
return accessOf({ user: current.owner }, item) !== "none";
|
|
26
|
+
}
|
package/dist/card.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { KnowledgeDb } from "./db.js";
|
|
2
|
+
export interface KnowledgeCard {
|
|
3
|
+
title: string;
|
|
4
|
+
text: string;
|
|
5
|
+
/** ISO date of the card (from `now`). */
|
|
6
|
+
date: string;
|
|
7
|
+
/** Recall handles cited by the entity's fields. */
|
|
8
|
+
sources: string[];
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Render the review card for one entity: title, one line per field with
|
|
12
|
+
* its observed date (and a stale mark past the field's horizon), the
|
|
13
|
+
* inquiries the entity has roles in, and the cited recall handles.
|
|
14
|
+
*/
|
|
15
|
+
export declare function renderCard(db: KnowledgeDb, entityId: string, now: number): KnowledgeCard;
|