@haverstack/record-adapter-do-sqlite 0.27.0 → 0.28.0

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/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { StackConflictError, applyMergePatch, StackNotFoundError, assertOneSurface, StackVersionConflictError, StackBadRequestError, NATIVE_SORT_FIELDS } from '@haverstack/core';
2
- import { OwnerMismatchError, AUTHORITY_ASSOCIATION_KINDS, contentSortEntry, partitionAssociations, constantSortEntry, contentSortKey, parseContentFilterKey } from '@haverstack/core/adapter';
1
+ import { StackConflictError, applyMergePatch, StackNotFoundError, StackVersionConflictError, StackBadRequestError, NATIVE_SORT_FIELDS } from '@haverstack/core';
2
+ import { OwnerMismatchError, softDeletedRefusal, sameAssociationSet, ElementSetMovedError, contentSortEntry, constantSortEntry, contentSortKey, parseContentFilterKey } from '@haverstack/core/adapter';
3
3
 
4
4
  // ../sqlite-shared/dist/schema.js
5
5
  var RECORD_SCHEMA_SQL = `
@@ -20,34 +20,43 @@ var RECORD_SCHEMA_SQL = `
20
20
  unlisted_at INTEGER
21
21
  ) STRICT;
22
22
 
23
- -- Every edge a record carries, authority and data alike: a permission
24
- -- has element identity and a delta shaped like a tag's, so it earns the
25
- -- same table. The permissions and associations fields are projections
26
- -- over it, partitioned by kind.
27
- -- See docs/spec/access-control.md \xA7 Record-level permissions.
23
+ -- A record's data associations: tags, attachments and relationships.
24
+ -- Its permissions are in their own table below, so no predicate over this
25
+ -- one can match a grantee as a relationship target.
28
26
  CREATE TABLE IF NOT EXISTS associations (
29
27
  record_id TEXT NOT NULL REFERENCES records(id),
30
- kind TEXT NOT NULL CHECK (kind IN ('tag', 'attachment', 'relationship', 'permission', 'anyone')),
28
+ kind TEXT NOT NULL CHECK (kind IN ('tag', 'attachment', 'relationship')),
31
29
  label TEXT NOT NULL,
32
30
  file_id TEXT NOT NULL DEFAULT '',
33
31
  -- A relationship target is (related_scope, related_id) qualified by
34
- -- related_ns (a foreign namespace) or related_stack (another stack); a
35
- -- permission's grantee reuses scope and id. All are in the key, so
36
- -- targets differing only by a qualifier are distinct associations.
37
- related_scope TEXT NOT NULL DEFAULT '' CHECK (related_scope IN ('', 'record', 'entity', 'external', 'group')),
32
+ -- related_ns (a foreign namespace) or related_stack (another stack).
33
+ -- All are in the key, so targets differing only by a qualifier are
34
+ -- distinct associations.
35
+ related_scope TEXT NOT NULL DEFAULT '' CHECK (related_scope IN ('', 'record', 'entity', 'external')),
38
36
  related_id TEXT NOT NULL DEFAULT '',
39
37
  related_ns TEXT NOT NULL DEFAULT '',
40
38
  related_stack TEXT NOT NULL DEFAULT '',
41
- -- Set only by a group grantee; in the key because member and admin name
42
- -- two different sets of people.
43
- related_role TEXT NOT NULL DEFAULT '' CHECK (related_role IN ('', 'member', 'admin')),
44
39
  -- Out of the key: it annotates a reference rather than naming one, so
45
40
  -- re-pointing it updates the existing row. See docs/spec/attachments.md
46
41
  -- \xA7 Naming the upload a reference came from.
47
42
  attachment_record_id TEXT NOT NULL DEFAULT '',
48
43
  -- Leads with record_id, so its implicit index serves every by-record
49
44
  -- read and delete; the indexes below cover only the filter directions.
50
- PRIMARY KEY (record_id, kind, label, file_id, related_scope, related_id, related_ns, related_stack, related_role)
45
+ PRIMARY KEY (record_id, kind, label, file_id, related_scope, related_id, related_ns, related_stack)
46
+ ) STRICT;
47
+
48
+ -- Who reaches a record, one row per element. An 'anyone' row names no
49
+ -- grantee; a group grantee's role is in the key, because member and
50
+ -- admin name two different sets of people.
51
+ -- See docs/spec/access-control.md \xA7 Record-level permissions.
52
+ CREATE TABLE IF NOT EXISTS permissions (
53
+ record_id TEXT NOT NULL REFERENCES records(id),
54
+ kind TEXT NOT NULL CHECK (kind IN ('permission', 'anyone')),
55
+ label TEXT NOT NULL CHECK (label IN ('read', 'write')),
56
+ grantee_kind TEXT NOT NULL DEFAULT '' CHECK (grantee_kind IN ('', 'entity', 'group')),
57
+ grantee_id TEXT NOT NULL DEFAULT '',
58
+ role TEXT NOT NULL DEFAULT '' CHECK (role IN ('', 'member', 'admin')),
59
+ PRIMARY KEY (record_id, kind, label, grantee_kind, grantee_id, role)
51
60
  ) STRICT;
52
61
 
53
62
  -- Content and the type it is read under. No parent_id, unlisted_at or
@@ -149,6 +158,11 @@ var RECORD_SCHEMA_SQL = `
149
158
  CREATE INDEX IF NOT EXISTS idx_assoc_related ON associations(kind, related_scope, related_ns, related_id, record_id);
150
159
  CREATE INDEX IF NOT EXISTS idx_types_base_id ON types(base_id, version);
151
160
 
161
+ -- The permissions key with record_id moved last, so a scoped query reads
162
+ -- the records carrying an element straight out of the index.
163
+ CREATE INDEX IF NOT EXISTS idx_permissions_element
164
+ ON permissions(kind, label, grantee_kind, grantee_id, role, record_id);
165
+
152
166
  -- The whole content ordering, in index order, for one field at a time:
153
167
  -- a content-sorted page is an ordered walk of a slice of this index.
154
168
  CREATE INDEX IF NOT EXISTS idx_content_index_sort
@@ -320,24 +334,22 @@ var decodeCursor = (cursor) => {
320
334
  throw malformed();
321
335
  };
322
336
  var byTypeDescriptor = (byType) => JSON.stringify(Object.entries(byType).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
323
- var getSortField = (query) => query.sort?.field ?? "createdAt";
324
337
  var getSortColumn = (field) => field === "createdAt" ? "created_at" : field === "updatedAt" ? "updated_at" : "version";
325
- var makeCursor = (record, sort, kindOf) => {
326
- const byType = sort?.contentFieldByType;
338
+ var makeCursor = (record, sort, kindOf2) => {
339
+ const byType = sort.contentFieldByType;
327
340
  if (byType) {
328
- const field = byTypeDescriptor(byType);
341
+ const field2 = byTypeDescriptor(byType);
329
342
  const key = Object.hasOwn(byType, record.typeId) ? byType[record.typeId] : void 0;
330
- const kind2 = key?.field === void 0 ? void 0 : kindOf?.(record, key.field);
343
+ const kind2 = key?.field === void 0 ? void 0 : kindOf2?.(record, key.field);
331
344
  const entry2 = key?.field !== void 0 ? kind2 && contentSortEntry(kind2, record.content[key.field]) : key && constantSortEntry(key.value ?? null);
332
- return encodeEntry(field, entry2 || null, record.id);
345
+ return encodeEntry(field2, entry2 || null, record.id);
333
346
  }
334
- const contentField = sort?.contentField;
335
- if (contentField === void 0) {
336
- const field = getSortField({ sort });
347
+ const { contentField, field } = sort;
348
+ if (field !== void 0) {
337
349
  const value = field === "updatedAt" ? record.updatedAt.getTime() : field === "version" ? record.version : record.createdAt.getTime();
338
350
  return encodeCursor({ kind: "native", field, value, id: record.id });
339
351
  }
340
- const kind = kindOf?.(record, contentField);
352
+ const kind = kindOf2?.(record, contentField);
341
353
  const entry = kind ? contentSortEntry(kind, record.content[contentField]) : null;
342
354
  return encodeEntry(contentField, entry, record.id);
343
355
  };
@@ -347,38 +359,182 @@ var encodeEntry = (field, entry, id) => {
347
359
  return entry.kind === "num" ? encodeCursor({ kind: "num", field, value: entry.num, id }) : encodeCursor({ kind: "text", field, value: entry.text, id });
348
360
  };
349
361
 
362
+ // ../sqlite-shared/dist/mappers.js
363
+ var toMs = (d) => d.getTime();
364
+ var actorFromColumns = (subject, principal) => subject == null ? void 0 : {
365
+ subjectId: subject,
366
+ ...principal != null && { principalId: principal }
367
+ };
368
+ var fromMs = (ms) => new Date(ms);
369
+ var rowToRecord = (row, associations, permissions) => {
370
+ const record = {
371
+ id: row.id,
372
+ typeId: row.type_id,
373
+ createdAt: fromMs(row.created_at),
374
+ updatedAt: fromMs(row.updated_at),
375
+ content: JSON.parse(row.content),
376
+ version: row.version
377
+ };
378
+ if (row.parent_id != null)
379
+ record.parentId = row.parent_id;
380
+ if (row.app_id != null)
381
+ record.appId = row.app_id;
382
+ const createdBy = actorFromColumns(row.entity_id, row.principal_id);
383
+ if (createdBy)
384
+ record.createdBy = createdBy;
385
+ const updatedBy = actorFromColumns(row.updated_by, row.updated_via);
386
+ if (updatedBy)
387
+ record.updatedBy = updatedBy;
388
+ if (row.deleted_at != null)
389
+ record.deletedAt = fromMs(row.deleted_at);
390
+ if (row.unlisted_at != null)
391
+ record.unlistedAt = fromMs(row.unlisted_at);
392
+ if (associations.length)
393
+ record.associations = associations;
394
+ if (permissions.length)
395
+ record.permissions = permissions;
396
+ return record;
397
+ };
398
+ var rowToAssociation = (row) => {
399
+ if (row.kind === "tag") {
400
+ return { kind: "tag", label: row.label };
401
+ }
402
+ if (row.kind === "attachment") {
403
+ const attachmentRecordId = row.attachment_record_id;
404
+ return {
405
+ kind: "attachment",
406
+ label: row.label,
407
+ fileId: row.file_id,
408
+ // Stored as '' when absent, like every other optional column here —
409
+ // the field is omitted rather than served empty, so a round trip
410
+ // produces the association the caller wrote.
411
+ ...attachmentRecordId && { attachmentRecordId }
412
+ };
413
+ }
414
+ return {
415
+ kind: "relationship",
416
+ label: row.label,
417
+ target: rowToTarget(row)
418
+ };
419
+ };
420
+ var associationKeyColumns = (a) => {
421
+ if (a.kind === "attachment")
422
+ return [a.fileId, "", "", "", ""];
423
+ if (a.kind !== "relationship")
424
+ return ["", "", "", "", ""];
425
+ const t = a.target;
426
+ if (t.kind === "entity")
427
+ return ["", "entity", t.entityId, "", ""];
428
+ if (t.kind === "external")
429
+ return ["", "external", t.id, t.ns, ""];
430
+ return ["", "record", t.recordId, "", t.stackUrl ?? ""];
431
+ };
432
+ var permissionKeyColumns = (p) => {
433
+ if (p.kind === "anyone")
434
+ return ["", "", ""];
435
+ const g = p.grantee;
436
+ return g.kind === "entity" ? ["entity", g.entityId, ""] : ["group", g.groupId, g.role];
437
+ };
438
+ var rowToPermission = (row) => {
439
+ if (row.kind === "anyone")
440
+ return { kind: "anyone", label: "read" };
441
+ const id = row.grantee_id;
442
+ return {
443
+ kind: "permission",
444
+ label: row.label,
445
+ grantee: row.grantee_kind === "group" ? { kind: "group", groupId: id, role: row.role } : { kind: "entity", entityId: id }
446
+ };
447
+ };
448
+ var rowToTarget = (row) => {
449
+ const id = row.related_id;
450
+ if (row.related_scope === "entity")
451
+ return { kind: "entity", entityId: id };
452
+ if (row.related_scope === "external") {
453
+ return { kind: "external", ns: row.related_ns, id };
454
+ }
455
+ const stackUrl = row.related_stack;
456
+ return { kind: "record", recordId: id, ...stackUrl && { stackUrl } };
457
+ };
458
+ var rowToType = (row) => {
459
+ const t = {
460
+ id: row.id,
461
+ baseId: row.base_id,
462
+ version: row.version,
463
+ name: row.name,
464
+ schema: JSON.parse(row.schema),
465
+ schemaHash: row.schema_hash,
466
+ createdAt: fromMs(row.created_at)
467
+ };
468
+ if (row.migrates_from)
469
+ t.migratesFrom = row.migrates_from;
470
+ return t;
471
+ };
472
+ var rowToVersion = (row) => {
473
+ const v = {
474
+ version: row.version,
475
+ typeId: row.type_id,
476
+ content: JSON.parse(row.content),
477
+ updatedAt: fromMs(row.updated_at)
478
+ };
479
+ const createdBy = actorFromColumns(row.entity_id, row.principal_id);
480
+ if (createdBy)
481
+ v.createdBy = createdBy;
482
+ const updatedBy = actorFromColumns(row.updated_by, row.updated_via);
483
+ if (updatedBy)
484
+ v.updatedBy = updatedBy;
485
+ return v;
486
+ };
487
+ var rowToJournalEntry = (row) => {
488
+ const e = {
489
+ seq: row.seq,
490
+ at: fromMs(row.at),
491
+ kind: row.kind,
492
+ ops: JSON.parse(row.ops),
493
+ version: row.version,
494
+ typeId: row.type_id
495
+ };
496
+ if (row.parent_id != null)
497
+ e.parentId = row.parent_id;
498
+ if (row.previous_parent_id != null) {
499
+ e.previousParentId = row.previous_parent_id === "" ? null : row.previous_parent_id;
500
+ }
501
+ if (row.actor != null)
502
+ e.actor = JSON.parse(row.actor);
503
+ if (row.associations != null)
504
+ e.associations = JSON.parse(row.associations);
505
+ return e;
506
+ };
507
+
350
508
  // ../sqlite-shared/dist/query.js
351
- var atBudget = (page, rows) => ({
509
+ var atBudget = (page, rows2) => ({
352
510
  sql: page.sql,
353
- params: [...page.params, rows]
511
+ params: [...page.params, rows2]
354
512
  });
355
- var sqlDirection = (query) => {
356
- const dir = query.sort ? query.sort.direction : "desc";
357
- if (dir !== "asc" && dir !== "desc") {
358
- throw new StackBadRequestError(`Invalid sort direction "${dir}": expected "asc" or "desc".`);
359
- }
360
- return dir === "asc" ? "ASC" : "DESC";
513
+ var sqlDirection = (query) => query.sort.direction === "asc" ? "ASC" : "DESC";
514
+ var spread = (alias) => `CASE ${alias}.type WHEN 'array' THEN ${alias}.value WHEN 'object' THEN json_array(json(${alias}.value)) WHEN 'true' THEN '[true]' WHEN 'false' THEN '[false]' ELSE json_array(${alias}.value) END`;
515
+ var members = (alias) => `CASE WHEN ${alias}.t = 'object' THEN ${alias}.v ELSE '{}' END`;
516
+ var emptyStatement = () => ({
517
+ tables: [],
518
+ tableParams: [],
519
+ conditions: ["r.id != '_config'"],
520
+ params: []
521
+ });
522
+ var rows = (s, columns, tuples) => {
523
+ const name = `t${s.tables.length}`;
524
+ s.tables.push(`${name} AS MATERIALIZED ${rowsTable(columns)}`);
525
+ s.tableParams.push(JSON.stringify(tuples));
526
+ return name;
527
+ };
528
+ var rowsTable = (columns) => {
529
+ const named = columns.map((column, i) => `json_extract(value, '$[${i}]') AS ${column}`);
530
+ return `(SELECT ${named.join(", ")} FROM json_each(?))`;
361
531
  };
362
- var spread = (alias) => `CASE WHEN ${alias}.type = 'array' THEN ${alias}.value WHEN ${alias}.type = 'object' THEN json_array(json(${alias}.value)) ELSE json_array(${alias}.value) END`;
363
- var members = (alias) => `CASE WHEN ${alias}.type = 'object' THEN ${alias}.value ELSE '{}' END`;
364
- var contentPathExists = (segments, leaf, params, nextAlias) => {
365
- let key = nextAlias();
366
- let value = nextAlias();
367
- const from = [`json_each(r.content) AS ${key}`, `json_each(${spread(key)}) AS ${value}`];
368
- const where = [`${key}.key = ?`];
369
- params.push(segments[0]);
370
- for (const segment of segments.slice(1)) {
371
- key = nextAlias();
372
- from.push(`json_each(${members(value)}) AS ${key}`);
373
- value = nextAlias();
374
- from.push(`json_each(${spread(key)}) AS ${value}`);
375
- where.push(`${key}.key = ?`);
376
- params.push(segment);
377
- }
378
- where.push(leaf(value));
379
- return `EXISTS (SELECT 1 FROM ${from.join(", ")} WHERE ${where.join(" AND ")})`;
532
+ var CONDITION_COLUMNS = ["scope", "path", "op", "val", "kind"];
533
+ var kindOf = (type) => `CASE ${type} WHEN 'integer' THEN 'number' WHEN 'real' THEN 'number' ELSE ${type} END`;
534
+ var holds = (c, r) => {
535
+ const depth = `json_array_length(${c}.path)`;
536
+ return `(WITH RECURSIVE w(d, v, t) AS (SELECT 0, ${r}.content, 'object' UNION ALL SELECT w.d + 1, e.value, e.type FROM w JOIN json_each(${members("w")}) m JOIN json_each(${spread("m")}) e WHERE w.d < ${depth} AND m.key = json_extract(${c}.path, '$[' || w.d || ']')) SELECT CASE ${c}.op WHEN 'equals' THEN IFNULL(MAX(${kindOf("w.t")} = ${c}.kind AND w.v = ${c}.val), 0) WHEN 'present' THEN IFNULL(MAX(w.t != 'null'), 0) WHEN 'isNull' THEN IFNULL(MAX(w.t = 'null'), 0) OR NOT IFNULL(MAX(w.t != 'null'), 0) END FROM w WHERE w.d = ${depth})`;
380
537
  };
381
- var equalsLeaf = (alias) => `${alias}.type NOT IN ('object', 'array') AND ${alias}.value = ?`;
382
538
  var SORT_ALIAS = "cs";
383
539
  var cursorMismatch = (cursor, field) => new StackBadRequestError(`Cursor sort field "${cursor.field}" does not match query sort field "${field}"`);
384
540
  var nativeCursor = (cursor, field) => {
@@ -391,37 +547,77 @@ var contentCursor = (cursor, field) => {
391
547
  throw cursorMismatch(cursor, field);
392
548
  return cursor;
393
549
  };
394
- var contentConditions = (predicate, params, nextAlias) => {
395
- const conditions = [];
396
- for (const [key, value] of Object.entries(predicate.content ?? {})) {
397
- const segments = parseContentFilterKey(key);
398
- if (value === null || value === void 0) {
399
- const isNull = contentPathExists(segments, (a) => `${a}.value IS NULL`, params, nextAlias);
400
- const anyValue = contentPathExists(segments, (a) => `${a}.value IS NOT NULL`, params, nextAlias);
401
- conditions.push(`(${isNull} OR NOT ${anyValue})`);
402
- } else {
403
- const match = contentPathExists(segments, equalsLeaf, params, nextAlias);
404
- params.push(value);
405
- conditions.push(match);
406
- }
550
+ var valueKind = (value) => {
551
+ if (typeof value === "boolean")
552
+ return `${value}`;
553
+ if (typeof value === "number")
554
+ return "number";
555
+ return typeof value === "string" ? "text" : null;
556
+ };
557
+ var contentRows = (scope, predicate) => {
558
+ const row = (key, op, value = null) => {
559
+ const path = parseContentFilterKey(key);
560
+ return [scope, path, op, value, valueKind(value)];
561
+ };
562
+ return [
563
+ ...Object.entries(predicate.content ?? {}).map(([key, value]) => (
564
+ // undefined is the same absence null names — JSON drops it on the way
565
+ // over the wire, so treating it as a value would make an in-process
566
+ // query and its wire equivalent disagree.
567
+ value === null || value === void 0 ? row(key, "isNull") : row(key, "equals", value)
568
+ )),
569
+ ...(predicate.contentPresent ?? []).map((key) => row(key, "present"))
570
+ ];
571
+ };
572
+ var inList = (s, column, values) => {
573
+ if (values.length === 1) {
574
+ s.params.push(values[0]);
575
+ return `${column} = ?`;
407
576
  }
408
- for (const key of predicate.contentPresent ?? []) {
409
- conditions.push(contentPathExists(parseContentFilterKey(key), (a) => `${a}.value IS NOT NULL`, params, nextAlias));
577
+ const table = rows(s, ["value"], values.map((value) => [value]));
578
+ return `${column} IN (SELECT value FROM ${table})`;
579
+ };
580
+ var familyRange = (baseId) => [`${baseId}@`, `${baseId}A`];
581
+ var withinFamily = (column, lo, hi) => `(${column} >= ${lo} AND ${column} < ${hi})`;
582
+ var inFamily = (column, baseId) => ({
583
+ sql: withinFamily(column, "?", "?"),
584
+ params: familyRange(baseId)
585
+ });
586
+ var inFamilies = (s, record, baseIds) => {
587
+ if (baseIds.length === 1) {
588
+ const one = inFamily(`${record}.type_id`, baseIds[0]);
589
+ s.params.push(...one.params);
590
+ return one.sql;
591
+ }
592
+ const table = rows(s, ["lo", "hi"], baseIds.map(familyRange));
593
+ return `${record}.id IN (SELECT fr.id FROM ${table} f JOIN records fr ON ${withinFamily("fr.type_id", "f.lo", "f.hi")})`;
594
+ };
595
+ var carriesPermissions = (s, sets) => {
596
+ const elements = rows(s, ["set_index", "set_size", "kind", "label", "grantee_kind", "grantee_id", "role"], sets.flatMap((set, i) => set.map((p) => [i, set.length, p.kind, p.label, ...permissionKeyColumns(p)])));
597
+ return `r.id IN (SELECT p.record_id FROM ${elements} e JOIN permissions p ON p.kind = e.kind AND p.label = e.label AND p.grantee_kind = e.grantee_kind AND p.grantee_id = e.grantee_id AND p.role = e.role GROUP BY p.record_id, e.set_index HAVING COUNT(*) = MAX(e.set_size))`;
598
+ };
599
+ var reachConditions = (s, reach) => {
600
+ if (reach.fence)
601
+ s.conditions.push(inFamilies(s, "r", reach.fence));
602
+ const within = reach.within;
603
+ if (within) {
604
+ const arms = [inFamilies(s, "r", within.baseIds)];
605
+ if (within.authored) {
606
+ s.params.push(within.authored.subjectId);
607
+ arms.push(`(r.entity_id = ? AND ${inFamilies(s, "r", within.authored.baseIds)})`);
608
+ }
609
+ arms.push(carriesPermissions(s, within.permissions));
610
+ s.conditions.push(`(${arms.join(" OR ")})`);
410
611
  }
411
- return conditions;
412
612
  };
413
- var recordConditions = (query) => {
414
- const conditions = ["r.id != '_config'"];
415
- const params = [];
613
+ var recordConditions = (query, reach) => {
614
+ const s = emptyStatement();
615
+ const { conditions, params } = s;
416
616
  const f = query.filter ?? {};
417
- let aliasSeq = 0;
418
- const nextContentAlias = () => `cp${aliasSeq++}`;
419
617
  const inFilter = (column, value) => {
420
618
  if (value === void 0)
421
619
  return;
422
- const values = Array.isArray(value) ? value : [value];
423
- conditions.push(`r.${column} IN (${values.map(() => "?").join(",")})`);
424
- params.push(...values);
620
+ conditions.push(inList(s, `r.${column}`, Array.isArray(value) ? value : [value]));
425
621
  };
426
622
  const rangeFilter = (column, range) => {
427
623
  if (range?.after) {
@@ -439,7 +635,12 @@ var recordConditions = (query) => {
439
635
  if (!f.includeUnlisted) {
440
636
  conditions.push("r.unlisted_at IS NULL");
441
637
  }
638
+ if (reach)
639
+ reachConditions(s, reach);
442
640
  inFilter("type_id", f.typeId);
641
+ if (f.baseId !== void 0) {
642
+ conditions.push(inFamilies(s, "r", Array.isArray(f.baseId) ? f.baseId : [f.baseId]));
643
+ }
443
644
  if (f.parentId !== void 0) {
444
645
  if (f.parentId === null) {
445
646
  conditions.push("r.parent_id IS NULL");
@@ -455,10 +656,10 @@ var recordConditions = (query) => {
455
656
  rangeFilter("updated_at", f.updatedAt);
456
657
  const hasAssociation = (kind, clauses) => `r.id IN (SELECT a.record_id FROM associations a WHERE a.kind = '${kind}'` + clauses.map((c) => ` AND ${c}`).join("") + `)`;
457
658
  if (f.tags?.length) {
458
- for (const tag of f.tags) {
459
- conditions.push(hasAssociation("tag", ["a.label = ?"]));
460
- params.push(tag);
461
- }
659
+ const tags = [...new Set(f.tags)];
660
+ const label = inList(s, "a.label", tags);
661
+ conditions.push(`r.id IN (SELECT a.record_id FROM associations a WHERE a.kind = 'tag' AND ${label} GROUP BY a.record_id HAVING COUNT(DISTINCT a.label) = ?)`);
662
+ params.push(tags.length);
462
663
  }
463
664
  if (f.attachment) {
464
665
  const clauses = [];
@@ -504,15 +705,17 @@ var recordConditions = (query) => {
504
705
  }
505
706
  conditions.push(hasAssociation("relationship", clauses));
506
707
  }
507
- if (f.content || f.contentPresent?.length) {
508
- conditions.push(...contentConditions(f, params, nextContentAlias));
509
- }
708
+ const content = contentRows(null, f);
510
709
  if (f.contentByType) {
511
- const arms = Object.entries(f.contentByType).map(([typeId, arm]) => {
512
- params.push(typeId);
513
- return ["r.type_id = ?", ...contentConditions(arm, params, nextContentAlias)].join(" AND ");
514
- });
515
- conditions.push(`(${arms.map((arm) => `(${arm})`).join(" OR ")})`);
710
+ const arms = Object.entries(f.contentByType);
711
+ const typeIds = arms.map(([typeId]) => typeId);
712
+ conditions.push(inList(s, "r.type_id", typeIds));
713
+ for (const [typeId, arm] of arms)
714
+ content.push(...contentRows(typeId, arm));
715
+ }
716
+ if (content.length > 0) {
717
+ const table = rows(s, CONDITION_COLUMNS, content);
718
+ conditions.push(`NOT EXISTS (SELECT 1 FROM ${table} c WHERE (c.scope IS NULL OR c.scope = r.type_id) AND NOT ${holds("c", "r")})`);
516
719
  }
517
720
  if (f.search) {
518
721
  const sanitized = sanitizeFts5Query(f.search);
@@ -523,7 +726,7 @@ var recordConditions = (query) => {
523
726
  conditions.push("0");
524
727
  }
525
728
  }
526
- return { conditions, params };
729
+ return s;
527
730
  };
528
731
  var presentCursorCondition = (cursor, op, params) => {
529
732
  if (cursor.kind === "num") {
@@ -534,56 +737,54 @@ var presentCursorCondition = (cursor, op, params) => {
534
737
  params.push(key, key, cursor.value, cursor.value, cursor.id);
535
738
  return `(${SORT_ALIAS}.value_rank ${op} 1 OR (${SORT_ALIAS}.value_rank = 1 AND (${SORT_ALIAS}.text_key ${op} ? OR (${SORT_ALIAS}.text_key = ? AND (${SORT_ALIAS}.text_value ${op} ? OR (${SORT_ALIAS}.text_value = ? AND ${SORT_ALIAS}.record_id ${op} ?))))))`;
536
739
  };
537
- var statement = (select, from, conditions, params, tail = "") => ({
538
- sql: `SELECT ${select} FROM ${from} WHERE ${conditions.join(" AND ")}${tail}`,
539
- params
540
- });
541
- var buildQueryPlan = (query) => {
740
+ var statement = (s, from, tail) => {
741
+ const select = `SELECT r.* FROM ${from} WHERE ${s.conditions.join(" AND ")}${tail}`;
742
+ const sql = s.tables.length === 0 ? select : `WITH ${s.tables.join(", ")} ${select}`;
743
+ return { sql, params: [...s.tableParams, ...s.params] };
744
+ };
745
+ var buildQueryPlan = (query, reach) => {
542
746
  const dir = sqlDirection(query);
543
747
  const op = dir === "ASC" ? ">" : "<";
544
748
  const decoded = query.cursor ? decodeCursor(query.cursor) : null;
545
- if (query.sort?.contentFieldByType) {
546
- return byTypePlan(query, query.sort.contentFieldByType, dir, op, decoded);
749
+ if (query.sort.contentFieldByType) {
750
+ return byTypePlan(query, reach, query.sort.contentFieldByType, dir, op, decoded);
547
751
  }
548
- const contentField = query.sort?.contentField;
549
- if (contentField === void 0) {
550
- const { conditions, params } = recordConditions(query);
551
- const field = getSortField(query);
752
+ const { contentField, field } = query.sort;
753
+ if (field !== void 0) {
754
+ const where = recordConditions(query, reach);
552
755
  const col = getSortColumn(field);
553
756
  if (decoded) {
554
757
  const cursor2 = nativeCursor(decoded, field);
555
- conditions.push(`(r.${col} ${op} ? OR (r.${col} = ? AND r.id ${op} ?))`);
556
- params.push(cursor2.value, cursor2.value, cursor2.id);
758
+ where.conditions.push(`(r.${col} ${op} ? OR (r.${col} = ? AND r.id ${op} ?))`);
759
+ where.params.push(cursor2.value, cursor2.value, cursor2.id);
557
760
  }
558
- return [
559
- statement("r.*", "records r", conditions, params, ` ORDER BY r.${col} ${dir}, r.id ${dir} LIMIT ?`)
560
- ];
761
+ return [statement(where, "records r", ` ORDER BY r.${col} ${dir}, r.id ${dir} LIMIT ?`)];
561
762
  }
562
763
  const cursor = decoded ? contentCursor(decoded, contentField) : null;
563
764
  const pages = [];
564
765
  if (cursor?.kind !== "absent") {
565
- const { conditions, params } = recordConditions(query);
766
+ const where = recordConditions(query, reach);
566
767
  const from = `content_index ${SORT_ALIAS} JOIN records r ON r.id = ${SORT_ALIAS}.record_id`;
567
- conditions.push(`${SORT_ALIAS}.field = ?`);
568
- params.push(contentField);
768
+ where.conditions.push(`${SORT_ALIAS}.field = ?`);
769
+ where.params.push(contentField);
569
770
  if (cursor)
570
- conditions.push(presentCursorCondition(cursor, op, params));
771
+ where.conditions.push(presentCursorCondition(cursor, op, where.params));
571
772
  const order = `${SORT_ALIAS}.value_rank ${dir}, ${SORT_ALIAS}.num_value ${dir}, ${SORT_ALIAS}.text_key ${dir}, ${SORT_ALIAS}.text_value ${dir}, ${SORT_ALIAS}.record_id ${dir}`;
572
- pages.push(statement("r.*", from, conditions, params, ` ORDER BY ${order} LIMIT ?`));
773
+ pages.push(statement(where, from, ` ORDER BY ${order} LIMIT ?`));
573
774
  }
574
775
  {
575
- const { conditions, params } = recordConditions(query);
576
- conditions.push(`NOT EXISTS (SELECT 1 FROM content_index ${SORT_ALIAS} WHERE ${SORT_ALIAS}.record_id = r.id AND ${SORT_ALIAS}.field = ?)`);
577
- params.push(contentField);
776
+ const where = recordConditions(query, reach);
777
+ where.conditions.push(`NOT EXISTS (SELECT 1 FROM content_index ${SORT_ALIAS} WHERE ${SORT_ALIAS}.record_id = r.id AND ${SORT_ALIAS}.field = ?)`);
778
+ where.params.push(contentField);
578
779
  if (cursor?.kind === "absent") {
579
- conditions.push(`r.id ${op} ?`);
580
- params.push(cursor.id);
780
+ where.conditions.push(`r.id ${op} ?`);
781
+ where.params.push(cursor.id);
581
782
  }
582
- pages.push(statement("r.*", "records r", conditions, params, ` ORDER BY r.id ${dir} LIMIT ?`));
783
+ pages.push(statement(where, "records r", ` ORDER BY r.id ${dir} LIMIT ?`));
583
784
  }
584
785
  return pages;
585
786
  };
586
- var byTypePlan = (query, byType, dir, op, decoded) => {
787
+ var byTypePlan = (query, reach, byType, dir, op, decoded) => {
587
788
  const cursor = decoded ? contentCursor(decoded, byTypeDescriptor(byType)) : null;
588
789
  const fields = [];
589
790
  const constants = [];
@@ -597,42 +798,38 @@ var byTypePlan = (query, byType, dir, op, decoded) => {
597
798
  constants.push([typeId, entry]);
598
799
  }
599
800
  const pages = [];
600
- const fieldOf = (alias, record) => fields.map(() => `(${record}.type_id = ? AND ${alias}.field = ?)`).join(" OR ");
601
801
  if (cursor?.kind !== "absent" && fields.length + constants.length > 0) {
802
+ const where = recordConditions(query, reach);
602
803
  const sources = [];
603
- const sourceParams = [];
604
804
  if (fields.length > 0) {
605
- sources.push(`SELECT c.record_id AS record_id, c.value_rank AS value_rank, c.num_value AS num_value, c.text_key AS text_key, c.text_value AS text_value FROM content_index c JOIN records t ON t.id = c.record_id WHERE ${fieldOf("c", "t")}`);
606
- for (const [typeId, field] of fields)
607
- sourceParams.push(typeId, field);
805
+ const byField = rows(where, ["type_id", "field"], fields);
806
+ sources.push(`SELECT ci.record_id AS record_id, ci.value_rank AS value_rank, ci.num_value AS num_value, ci.text_key AS text_key, ci.text_value AS text_value FROM ${byField} f JOIN content_index ci ON ci.field = f.field JOIN records t ON t.id = ci.record_id AND t.type_id = f.type_id`);
608
807
  }
609
- for (const [typeId, entry] of constants) {
610
- sources.push("SELECT t.id AS record_id, ? AS value_rank, ? AS num_value, ? AS text_key, ? AS text_value FROM records t WHERE t.type_id = ?");
611
- sourceParams.push(...entry.kind === "num" ? [0, entry.num, null, null] : [1, null, entry.key, entry.text], typeId);
808
+ if (constants.length > 0) {
809
+ const byConstant = rows(where, ["type_id", "value_rank", "num_value", "text_key", "text_value"], constants.map(([typeId, entry]) => entry.kind === "num" ? [typeId, 0, entry.num, null, null] : [typeId, 1, null, entry.key, entry.text]));
810
+ sources.push(`SELECT t.id AS record_id, k.value_rank AS value_rank, k.num_value AS num_value, k.text_key AS text_key, k.text_value AS text_value FROM ${byConstant} k JOIN records t ON t.type_id = k.type_id`);
612
811
  }
613
- const { conditions, params } = recordConditions(query);
614
812
  if (cursor)
615
- conditions.push(presentCursorCondition(cursor, op, params));
813
+ where.conditions.push(presentCursorCondition(cursor, op, where.params));
616
814
  const from = `(${sources.join(" UNION ALL ")}) ${SORT_ALIAS} JOIN records r ON r.id = ${SORT_ALIAS}.record_id`;
617
815
  const order = `${SORT_ALIAS}.value_rank ${dir}, ${SORT_ALIAS}.num_value ${dir}, ${SORT_ALIAS}.text_key ${dir}, ${SORT_ALIAS}.text_value ${dir}, ${SORT_ALIAS}.record_id ${dir}`;
618
- pages.push(statement("r.*", from, conditions, [...sourceParams, ...params], ` ORDER BY ${order} LIMIT ?`));
816
+ pages.push(statement(where, from, ` ORDER BY ${order} LIMIT ?`));
619
817
  }
620
818
  {
621
- const { conditions, params } = recordConditions(query);
819
+ const where = recordConditions(query, reach);
622
820
  if (fields.length > 0) {
623
- conditions.push(`NOT EXISTS (SELECT 1 FROM content_index ${SORT_ALIAS} WHERE ${SORT_ALIAS}.record_id = r.id AND (${fieldOf(SORT_ALIAS, "r")}))`);
624
- for (const [typeId, field] of fields)
625
- params.push(typeId, field);
821
+ const byField = rows(where, ["type_id", "field"], fields);
822
+ where.conditions.push(`NOT EXISTS (SELECT 1 FROM ${byField} f JOIN content_index ${SORT_ALIAS} ON ${SORT_ALIAS}.record_id = r.id AND ${SORT_ALIAS}.field = f.field WHERE f.type_id = r.type_id)`);
626
823
  }
627
824
  if (constants.length > 0) {
628
- conditions.push(`r.type_id NOT IN (${constants.map(() => "?").join(",")})`);
629
- params.push(...constants.map(([typeId]) => typeId));
825
+ const typeIds = constants.map(([typeId]) => typeId);
826
+ where.conditions.push(`NOT (${inList(where, "r.type_id", typeIds)})`);
630
827
  }
631
828
  if (cursor?.kind === "absent") {
632
- conditions.push(`r.id ${op} ?`);
633
- params.push(cursor.id);
829
+ where.conditions.push(`r.id ${op} ?`);
830
+ where.params.push(cursor.id);
634
831
  }
635
- pages.push(statement("r.*", "records r", conditions, params, ` ORDER BY r.id ${dir} LIMIT ?`));
832
+ pages.push(statement(where, "records r", ` ORDER BY r.id ${dir} LIMIT ?`));
636
833
  }
637
834
  return pages;
638
835
  };
@@ -725,154 +922,6 @@ var ContentIndex = class {
725
922
  return fields;
726
923
  }
727
924
  };
728
- var toMs = (d) => d.getTime();
729
- var actorFromColumns = (subject, principal) => subject == null ? void 0 : {
730
- subjectId: subject,
731
- ...principal != null && { principalId: principal }
732
- };
733
- var fromMs = (ms) => new Date(ms);
734
- var rowToRecord = (row, associations) => {
735
- const record = {
736
- id: row.id,
737
- typeId: row.type_id,
738
- createdAt: fromMs(row.created_at),
739
- updatedAt: fromMs(row.updated_at),
740
- content: JSON.parse(row.content),
741
- version: row.version
742
- };
743
- if (row.parent_id != null)
744
- record.parentId = row.parent_id;
745
- if (row.app_id != null)
746
- record.appId = row.app_id;
747
- const createdBy = actorFromColumns(row.entity_id, row.principal_id);
748
- if (createdBy)
749
- record.createdBy = createdBy;
750
- const updatedBy = actorFromColumns(row.updated_by, row.updated_via);
751
- if (updatedBy)
752
- record.updatedBy = updatedBy;
753
- if (row.deleted_at != null)
754
- record.deletedAt = fromMs(row.deleted_at);
755
- if (row.unlisted_at != null)
756
- record.unlistedAt = fromMs(row.unlisted_at);
757
- const partitioned = partitionAssociations(associations);
758
- if (partitioned.associations.length)
759
- record.associations = partitioned.associations;
760
- if (partitioned.permissions.length)
761
- record.permissions = partitioned.permissions;
762
- return record;
763
- };
764
- var rowToAssociation = (row) => {
765
- if (row.kind === "tag") {
766
- return { kind: "tag", label: row.label };
767
- }
768
- if (row.kind === "anyone") {
769
- return { kind: "anyone", label: "read" };
770
- }
771
- if (row.kind === "permission") {
772
- return {
773
- kind: "permission",
774
- label: row.label,
775
- grantee: rowToGrantee(row)
776
- };
777
- }
778
- if (row.kind === "attachment") {
779
- const attachmentRecordId = row.attachment_record_id;
780
- return {
781
- kind: "attachment",
782
- label: row.label,
783
- fileId: row.file_id,
784
- // Stored as '' when absent, like every other optional column here —
785
- // the field is omitted rather than served empty, so a round trip
786
- // produces the association the caller wrote.
787
- ...attachmentRecordId && { attachmentRecordId }
788
- };
789
- }
790
- return {
791
- kind: "relationship",
792
- label: row.label,
793
- target: rowToTarget(row)
794
- };
795
- };
796
- var associationKeyColumns = (a) => {
797
- if (a.kind === "attachment")
798
- return [a.fileId, "", "", "", "", ""];
799
- if (a.kind === "permission") {
800
- const g = a.grantee;
801
- return g.kind === "entity" ? ["", "entity", g.entityId, "", "", ""] : ["", "group", g.groupId, "", "", g.role];
802
- }
803
- if (a.kind !== "relationship")
804
- return ["", "", "", "", "", ""];
805
- const t = a.target;
806
- if (t.kind === "entity")
807
- return ["", "entity", t.entityId, "", "", ""];
808
- if (t.kind === "external")
809
- return ["", "external", t.id, t.ns, "", ""];
810
- return ["", "record", t.recordId, "", t.stackUrl ?? "", ""];
811
- };
812
- var rowToGrantee = (row) => row.related_scope === "group" ? {
813
- kind: "group",
814
- groupId: row.related_id,
815
- role: row.related_role
816
- } : { kind: "entity", entityId: row.related_id };
817
- var rowToTarget = (row) => {
818
- const id = row.related_id;
819
- if (row.related_scope === "entity")
820
- return { kind: "entity", entityId: id };
821
- if (row.related_scope === "external") {
822
- return { kind: "external", ns: row.related_ns, id };
823
- }
824
- const stackUrl = row.related_stack;
825
- return { kind: "record", recordId: id, ...stackUrl && { stackUrl } };
826
- };
827
- var rowToType = (row) => {
828
- const t = {
829
- id: row.id,
830
- baseId: row.base_id,
831
- version: row.version,
832
- name: row.name,
833
- schema: JSON.parse(row.schema),
834
- schemaHash: row.schema_hash,
835
- createdAt: fromMs(row.created_at)
836
- };
837
- if (row.migrates_from)
838
- t.migratesFrom = row.migrates_from;
839
- return t;
840
- };
841
- var rowToVersion = (row) => {
842
- const v = {
843
- version: row.version,
844
- typeId: row.type_id,
845
- content: JSON.parse(row.content),
846
- updatedAt: fromMs(row.updated_at)
847
- };
848
- const createdBy = actorFromColumns(row.entity_id, row.principal_id);
849
- if (createdBy)
850
- v.createdBy = createdBy;
851
- const updatedBy = actorFromColumns(row.updated_by, row.updated_via);
852
- if (updatedBy)
853
- v.updatedBy = updatedBy;
854
- return v;
855
- };
856
- var rowToJournalEntry = (row) => {
857
- const e = {
858
- seq: row.seq,
859
- at: fromMs(row.at),
860
- kind: row.kind,
861
- ops: JSON.parse(row.ops),
862
- version: row.version,
863
- typeId: row.type_id
864
- };
865
- if (row.parent_id != null)
866
- e.parentId = row.parent_id;
867
- if (row.previous_parent_id != null) {
868
- e.previousParentId = row.previous_parent_id === "" ? null : row.previous_parent_id;
869
- }
870
- if (row.actor != null)
871
- e.actor = JSON.parse(row.actor);
872
- if (row.associations != null)
873
- e.associations = JSON.parse(row.associations);
874
- return e;
875
- };
876
925
 
877
926
  // ../sqlite-shared/dist/record-logic.js
878
927
  var asStackBadRequestError = (query, run) => {
@@ -931,6 +980,18 @@ var SharedSqlRecordLogic = class {
931
980
  throw recordNotFound(id);
932
981
  throw versionConflict(id, expectedVersion, version);
933
982
  }
983
+ /**
984
+ * An aspect write's record, inside its transaction: missing is not found,
985
+ * a tombstone is refused, and anything else answers its version.
986
+ */
987
+ liveVersion(id) {
988
+ const row = this.exec.get("SELECT version, deleted_at FROM records WHERE id = ?", [id]);
989
+ if (!row)
990
+ throw recordNotFound(id);
991
+ if (row.deleted_at !== null)
992
+ throw softDeletedRefusal(id);
993
+ return row.version;
994
+ }
934
995
  currentVersion(id) {
935
996
  return this.exec.get("SELECT version FROM records WHERE id = ?", [id])?.version;
936
997
  }
@@ -1014,9 +1075,10 @@ var SharedSqlRecordLogic = class {
1014
1075
  }
1015
1076
  throw err;
1016
1077
  }
1017
- const edges = [...record.associations ?? [], ...record.permissions ?? []];
1018
- if (edges.length)
1019
- this.insertAssociations(record.id, edges);
1078
+ if (record.associations?.length)
1079
+ this.insertAssociations(record.id, record.associations);
1080
+ if (record.permissions?.length)
1081
+ this.insertPermissions(record.id, record.permissions);
1020
1082
  this.searchIndex.insert(record.id, JSON.stringify(record.content));
1021
1083
  this.contentIndex.sync(record.id, record.typeId, record.content);
1022
1084
  this.appendJournal(record.id, opts.journal);
@@ -1034,56 +1096,55 @@ var SharedSqlRecordLogic = class {
1034
1096
  */
1035
1097
  readRecord(id) {
1036
1098
  const row = this.exec.get("SELECT * FROM records WHERE id = ?", [id]);
1037
- return row ? this.hydrate(row) : null;
1099
+ return row ? this.hydrate([row])[0] : null;
1038
1100
  }
1039
- /** A records row with its association set attached. */
1040
- hydrate(row) {
1041
- return rowToRecord(row, this.getAssociationsForRecord(row.id));
1101
+ /**
1102
+ * Records rows with their associations and permissions attached: one
1103
+ * statement per element table for the whole list, so a page costs three
1104
+ * reads however many records it holds.
1105
+ */
1106
+ hydrate(rows2) {
1107
+ const ids = rows2.map((row) => row.id);
1108
+ const associations = this.elementsByRecord("associations", ids, rowToAssociation);
1109
+ const permissions = this.elementsByRecord("permissions", ids, rowToPermission);
1110
+ return rows2.map((row) => rowToRecord(row, associations.get(row.id) ?? [], permissions.get(row.id) ?? []));
1042
1111
  }
1043
1112
  /**
1044
- * One transaction, so one call is one version whatever it moved, and the
1045
- * patch applies to the content as it stands when the write lands.
1113
+ * The patch applies to the content as it stands when the write lands,
1114
+ * read inside the same transaction.
1046
1115
  */
1047
- async mutateRecord(id, changes, opts = {}) {
1048
- const now = toMs(/* @__PURE__ */ new Date());
1049
- const sets = [];
1050
- const values = [];
1051
- if (changes.parentId !== void 0) {
1052
- sets.push("parent_id = ?");
1053
- values.push(changes.parentId);
1054
- }
1055
- if (changes.unlisted !== void 0) {
1056
- sets.push("unlisted_at = ?");
1057
- values.push(changes.unlisted ? now : null);
1058
- }
1116
+ async patchContent(id, patch, opts = {}) {
1059
1117
  this.exec.transaction(() => {
1060
1118
  const current = this.readForWrite(id, opts.ifVersion);
1061
- if (opts.bumpsVersion === false) {
1062
- if (sets.length > 0) {
1063
- this.exec.run(`UPDATE records SET ${sets.join(", ")} WHERE id = ?`, [...values, id]);
1064
- }
1065
- this.replaceAssociationHalves(id, changes);
1066
- this.appendJournal(id, opts.journal);
1067
- return;
1068
- }
1069
- const merged = changes.contentPatch ? applyMergePatch(current.content, changes.contentPatch) : void 0;
1070
- if (merged !== void 0) {
1071
- sets.unshift("content = ?");
1072
- values.unshift(JSON.stringify(merged));
1073
- }
1074
1119
  if (opts.snapshot)
1075
1120
  this.snapshotBeforeMutation(id, opts.snapshot);
1076
- if (merged !== void 0)
1077
- this.searchIndex.remove(id);
1078
- this.versionedUpdate(id, sets, values, opts, now);
1079
- this.replaceAssociationHalves(id, changes);
1080
- if (merged !== void 0) {
1081
- this.searchIndex.insert(id, JSON.stringify(merged));
1082
- this.contentIndex.sync(id, current.typeId, merged);
1121
+ this.rewriteContent(id, current.typeId, applyMergePatch(current.content, patch), opts);
1122
+ this.appendJournal(id, opts.journal);
1123
+ });
1124
+ return this.reread(id, "patchContent");
1125
+ }
1126
+ async setParent(id, parentId, opts = {}) {
1127
+ return this.aspectUpdate(id, "parent_id = ?", parentId, opts, "setParent");
1128
+ }
1129
+ /** Stamped fresh: `Stack` hands this only a transition. */
1130
+ async setUnlisted(id, unlisted, opts = {}) {
1131
+ return this.aspectUpdate(id, "unlisted_at = ?", unlisted ? toMs(/* @__PURE__ */ new Date()) : null, opts, "setUnlisted");
1132
+ }
1133
+ /**
1134
+ * One records column that never bumps. The tombstone refusal and the
1135
+ * `ifVersion` guard ride in the WHERE clause, as on a versioned update.
1136
+ */
1137
+ aspectUpdate(id, set, value, opts, verb) {
1138
+ const guarded = opts.ifVersion !== void 0;
1139
+ this.exec.transaction(() => {
1140
+ const changed = this.exec.run(`UPDATE records SET ${set}
1141
+ WHERE id = ? AND deleted_at IS NULL${guarded ? " AND version = ?" : ""}`, [value, id, ...guarded ? [opts.ifVersion] : []]);
1142
+ if (changed === 0) {
1143
+ throw versionConflict(id, opts.ifVersion, this.liveVersion(id));
1083
1144
  }
1084
1145
  this.appendJournal(id, opts.journal);
1085
1146
  });
1086
- return this.reread(id, "mutateRecord");
1147
+ return this.reread(id, verb);
1087
1148
  }
1088
1149
  async deleteRecord(id, opts = {}) {
1089
1150
  if (opts.purge) {
@@ -1113,6 +1174,7 @@ var SharedSqlRecordLogic = class {
1113
1174
  this.checkExpectedVersion(id, purged.version, ifVersion);
1114
1175
  this.searchIndex.remove(id);
1115
1176
  this.exec.run("DELETE FROM associations WHERE record_id = ?", [id]);
1177
+ this.exec.run("DELETE FROM permissions WHERE record_id = ?", [id]);
1116
1178
  this.exec.run("DELETE FROM versions WHERE record_id = ?", [id]);
1117
1179
  this.exec.run("DELETE FROM journal WHERE record_id = ?", [id]);
1118
1180
  this.contentIndex.remove(id);
@@ -1163,20 +1225,27 @@ var SharedSqlRecordLogic = class {
1163
1225
  this.searchIndex.insert(id, json);
1164
1226
  this.contentIndex.sync(id, typeId, content);
1165
1227
  }
1166
- async queryRecords(query) {
1167
- const limit = query.limit ?? 50;
1228
+ queryRecords(query) {
1229
+ return this.page(query);
1230
+ }
1231
+ queryReadable(query, reach) {
1232
+ return this.page(query, reach);
1233
+ }
1234
+ /** `reach`, when given, is one more condition of every statement in the plan. */
1235
+ async page(query, reach) {
1236
+ const { limit } = query;
1168
1237
  const fetch = limit + 1;
1169
- const rows = [];
1170
- for (const page2 of buildQueryPlan(query)) {
1171
- const budget = fetch - rows.length;
1238
+ const rows2 = [];
1239
+ for (const page2 of buildQueryPlan(query, reach)) {
1240
+ const budget = fetch - rows2.length;
1172
1241
  if (budget <= 0)
1173
1242
  break;
1174
1243
  const statement2 = atBudget(page2, budget);
1175
- rows.push(...asStackBadRequestError(query, () => this.exec.all(statement2.sql, statement2.params)));
1244
+ rows2.push(...asStackBadRequestError(query, () => this.exec.all(statement2.sql, statement2.params)));
1176
1245
  }
1177
- const hasMore = rows.length > limit;
1178
- const page = hasMore ? rows.slice(0, limit) : rows;
1179
- const records = page.map((row) => this.hydrate(row));
1246
+ const hasMore = rows2.length > limit;
1247
+ const page = hasMore ? rows2.slice(0, limit) : rows2;
1248
+ const records = this.hydrate(page);
1180
1249
  const lastRecord = records[records.length - 1];
1181
1250
  const cursor = hasMore && lastRecord ? makeCursor(lastRecord, query.sort, (record, field) => this.contentIndex.kindOf(record.typeId, field)) : null;
1182
1251
  return { records, cursor };
@@ -1185,7 +1254,7 @@ var SharedSqlRecordLogic = class {
1185
1254
  * The reference check and the purges share one synchronous transaction,
1186
1255
  * so nothing can add a reference between the two.
1187
1256
  */
1188
- async deleteUnreferencedAttachmentRecords(fileId, metadataTypeIds) {
1257
+ async deleteUnreferencedAttachmentRecords(fileId, metadataBaseId) {
1189
1258
  return this.exec.transaction(() => {
1190
1259
  const referenced = this.exec.all(`SELECT 1 as found FROM associations WHERE kind = 'attachment' AND file_id = ?
1191
1260
  UNION ALL
@@ -1194,11 +1263,9 @@ var SharedSqlRecordLogic = class {
1194
1263
  if (referenced.length) {
1195
1264
  throw new StackConflictError("Attachment is still referenced by one or more records");
1196
1265
  }
1197
- if (metadataTypeIds.length === 0)
1198
- return [];
1199
- const placeholders = metadataTypeIds.map(() => "?").join(", ");
1266
+ const family = inFamily("type_id", metadataBaseId);
1200
1267
  const metaRows = this.exec.all(`SELECT id FROM records
1201
- WHERE type_id IN (${placeholders}) AND json_extract(content, '$.fileId') = ?`, [...metadataTypeIds, fileId]);
1268
+ WHERE ${family.sql} AND json_extract(content, '$.fileId') = ?`, [...family.params, fileId]);
1202
1269
  const deleted = [];
1203
1270
  for (const row of metaRows) {
1204
1271
  const purged = this.purgeRecord(row.id);
@@ -1218,8 +1285,8 @@ var SharedSqlRecordLogic = class {
1218
1285
  conditions.push("version < ?");
1219
1286
  values.push(query.beforeVersion);
1220
1287
  }
1221
- const rows = this.exec.all(`SELECT * FROM versions WHERE ${conditions.join(" AND ")} ORDER BY version DESC${query.limit !== void 0 ? " LIMIT ?" : ""}`, query.limit !== void 0 ? [...values, query.limit] : values);
1222
- return rows.map(rowToVersion);
1288
+ const rows2 = this.exec.all(`SELECT * FROM versions WHERE ${conditions.join(" AND ")} ORDER BY version DESC${query.limit !== void 0 ? " LIMIT ?" : ""}`, query.limit !== void 0 ? [...values, query.limit] : values);
1289
+ return rows2.map(rowToVersion);
1223
1290
  }
1224
1291
  async getVersion(id, version) {
1225
1292
  return this.readVersion(id, version);
@@ -1329,8 +1396,8 @@ var SharedSqlRecordLogic = class {
1329
1396
  conditions.push("seq > ?");
1330
1397
  values.push(query.afterSeq);
1331
1398
  }
1332
- const rows = this.exec.all(`SELECT * FROM journal WHERE ${conditions.join(" AND ")} ORDER BY seq ASC${query.limit !== void 0 ? " LIMIT ?" : ""}`, query.limit !== void 0 ? [...values, query.limit] : values);
1333
- return rows.map(rowToJournalEntry);
1399
+ const rows2 = this.exec.all(`SELECT * FROM journal WHERE ${conditions.join(" AND ")} ORDER BY seq ASC${query.limit !== void 0 ? " LIMIT ?" : ""}`, query.limit !== void 0 ? [...values, query.limit] : values);
1400
+ return rows2.map(rowToJournalEntry);
1334
1401
  }
1335
1402
  // -------------------------------------------------------
1336
1403
  // Types
@@ -1355,66 +1422,71 @@ var SharedSqlRecordLogic = class {
1355
1422
  return row ? rowToType(row) : null;
1356
1423
  }
1357
1424
  async listTypes() {
1358
- const rows = this.exec.all("SELECT * FROM types ORDER BY base_id, version");
1359
- return rows.map(rowToType);
1425
+ const rows2 = this.exec.all("SELECT * FROM types ORDER BY base_id, version");
1426
+ return rows2.map(rowToType);
1360
1427
  }
1361
1428
  // -------------------------------------------------------
1362
1429
  // Associations
1363
1430
  // -------------------------------------------------------
1364
1431
  /**
1365
- * Never bumps `version`/`updatedAt` and never snapshots — see
1366
- * StackRecordAdapter.amendAssociations(). Removes apply before adds, so
1367
- * removing and re-adding one identity in a call leaves it present.
1432
+ * A write to one of a record's two element tables, inside a transaction
1433
+ * that first checks the record exists, is not soft-deleted, is at
1434
+ * `ifVersion`, and that the table holds `expectSet`. Never bumps and
1435
+ * never snapshots.
1368
1436
  */
1369
- async amendAssociations(recordId, changes, opts = {}) {
1370
- assertOneSurface(changes);
1437
+ elementWrite(id, table, opts, write, verb) {
1371
1438
  this.exec.transaction(() => {
1372
- if (this.currentVersion(recordId) === void 0)
1373
- throw recordNotFound(recordId);
1374
- for (const change of changes) {
1375
- if (change.op !== "remove")
1376
- continue;
1377
- const { association } = change;
1378
- this.exec.run(`DELETE FROM associations
1379
- WHERE record_id = ?
1380
- AND kind = ?
1381
- AND label = ?
1382
- AND file_id = ?
1383
- AND related_scope = ?
1384
- AND related_id = ?
1385
- AND related_ns = ?
1386
- AND related_stack = ?
1387
- AND related_role = ?`, [recordId, association.kind, association.label, ...associationKeyColumns(association)]);
1439
+ this.checkExpectedVersion(id, this.liveVersion(id), opts.ifVersion);
1440
+ if (opts.expectSet) {
1441
+ const map = table === "associations" ? rowToAssociation : rowToPermission;
1442
+ const stored = this.elementsByRecord(table, [id], map).get(id) ?? [];
1443
+ if (!sameAssociationSet(stored, opts.expectSet))
1444
+ throw new ElementSetMovedError(id);
1388
1445
  }
1389
- const adds = changes.flatMap((c) => c.op === "add" ? [c.association] : []);
1390
- if (adds.length)
1391
- this.insertAssociations(recordId, adds);
1392
- this.appendJournal(recordId, opts.journal);
1446
+ write();
1447
+ this.appendJournal(id, opts.journal);
1393
1448
  });
1394
- return this.reread(recordId, "amendAssociations");
1449
+ return this.reread(id, verb);
1395
1450
  }
1396
- /**
1397
- * Each key replaces only within its own half of the partition, which is
1398
- * why a tag write cannot drop an ACL. Caller-transactional.
1399
- * See docs/spec/access-control.md § Record-level permissions.
1400
- */
1401
- replaceAssociationHalves(recordId, changes) {
1402
- if (changes.associations !== void 0) {
1403
- this.replaceAssociations(recordId, changes.associations, "data");
1404
- }
1405
- if (changes.permissions !== void 0) {
1406
- this.replaceAssociations(recordId, changes.permissions, "authority");
1407
- }
1451
+ async editAssociations(id, edits, opts = {}) {
1452
+ return this.elementWrite(id, "associations", opts, () => {
1453
+ for (const edit of edits) {
1454
+ if (edit.op !== "remove")
1455
+ continue;
1456
+ const association = edit.association;
1457
+ this.exec.run(`DELETE FROM associations
1458
+ WHERE record_id = ? AND kind = ? AND label = ?
1459
+ AND file_id = ? AND related_scope = ? AND related_id = ?
1460
+ AND related_ns = ? AND related_stack = ?`, [id, association.kind, association.label, ...associationKeyColumns(association)]);
1461
+ }
1462
+ this.insertAssociations(id, addsOf(edits));
1463
+ }, "editAssociations");
1464
+ }
1465
+ async setAssociations(id, associations, opts = {}) {
1466
+ return this.elementWrite(id, "associations", opts, () => {
1467
+ this.exec.run("DELETE FROM associations WHERE record_id = ?", [id]);
1468
+ this.insertAssociations(id, associations);
1469
+ }, "setAssociations");
1470
+ }
1471
+ /** editAssociations() over the permissions table. */
1472
+ async editAccess(id, edits, opts = {}) {
1473
+ return this.elementWrite(id, "permissions", opts, () => {
1474
+ for (const edit of edits) {
1475
+ if (edit.op !== "remove")
1476
+ continue;
1477
+ const permission = edit.association;
1478
+ this.exec.run(`DELETE FROM permissions
1479
+ WHERE record_id = ? AND kind = ? AND label = ?
1480
+ AND grantee_kind = ? AND grantee_id = ? AND role = ?`, [id, permission.kind, permission.label, ...permissionKeyColumns(permission)]);
1481
+ }
1482
+ this.insertPermissions(id, addsOf(edits));
1483
+ }, "editAccess");
1408
1484
  }
1409
- /**
1410
- * Swap one half of a record's association set wholesale — the "replace,
1411
- * don't merge" semantics a change set's association list carries. The data
1412
- * half is "not an authority kind", so core's list is the only one.
1413
- */
1414
- replaceAssociations(recordId, associations, half) {
1415
- this.exec.run(`DELETE FROM associations WHERE record_id = ? AND kind ${half === "data" ? "NOT IN" : "IN"} (${AUTHORITY_ASSOCIATION_KINDS.map(() => "?").join(", ")})`, [recordId, ...AUTHORITY_ASSOCIATION_KINDS]);
1416
- if (associations.length)
1417
- this.insertAssociations(recordId, associations);
1485
+ async setAccess(id, permissions, opts = {}) {
1486
+ return this.elementWrite(id, "permissions", opts, () => {
1487
+ this.exec.run("DELETE FROM permissions WHERE record_id = ?", [id]);
1488
+ this.insertPermissions(id, permissions);
1489
+ }, "setAccess");
1418
1490
  }
1419
1491
  /**
1420
1492
  * Upserts on association identity, so re-pointing `attachment_record_id`
@@ -1423,46 +1495,72 @@ var SharedSqlRecordLogic = class {
1423
1495
  */
1424
1496
  insertAssociations(recordId, associations) {
1425
1497
  for (const assoc of associations) {
1426
- try {
1427
- this.exec.run(`INSERT INTO associations
1498
+ this.insertRow(recordId, () => this.exec.run(`INSERT INTO associations
1428
1499
  (record_id, kind, label, file_id, related_scope, related_id, related_ns,
1429
- related_stack, related_role, attachment_record_id)
1430
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1500
+ related_stack, attachment_record_id)
1501
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1431
1502
  ON CONFLICT DO UPDATE SET attachment_record_id = excluded.attachment_record_id`, [
1432
- recordId,
1433
- assoc.kind,
1434
- assoc.label,
1435
- ...associationKeyColumns(assoc),
1436
- assoc.kind === "attachment" ? assoc.attachmentRecordId ?? "" : ""
1437
- ]);
1438
- } catch (err) {
1439
- if (isForeignKeyViolation(err)) {
1440
- throw recordNotFound(recordId);
1441
- }
1442
- throw err;
1443
- }
1503
+ recordId,
1504
+ assoc.kind,
1505
+ assoc.label,
1506
+ ...associationKeyColumns(assoc),
1507
+ assoc.kind === "attachment" ? assoc.attachmentRecordId ?? "" : ""
1508
+ ]));
1444
1509
  }
1445
1510
  }
1446
- getAssociationsForRecord(recordId) {
1447
- const rows = this.exec.all("SELECT * FROM associations WHERE record_id = ?", [recordId]);
1448
- return rows.map(rowToAssociation);
1511
+ /** Every column is in the key, so a repeated element is the same row. */
1512
+ insertPermissions(recordId, permissions) {
1513
+ for (const p of permissions) {
1514
+ this.insertRow(recordId, () => this.exec.run(`INSERT INTO permissions (record_id, kind, label, grantee_kind, grantee_id, role)
1515
+ VALUES (?, ?, ?, ?, ?, ?)
1516
+ ON CONFLICT DO NOTHING`, [recordId, p.kind, p.label, ...permissionKeyColumns(p)]));
1517
+ }
1518
+ }
1519
+ insertRow(recordId, insert) {
1520
+ try {
1521
+ insert();
1522
+ } catch (err) {
1523
+ if (isForeignKeyViolation(err))
1524
+ throw recordNotFound(recordId);
1525
+ throw err;
1526
+ }
1527
+ }
1528
+ /**
1529
+ * One element table's rows for every record in `ids`, in primary-key
1530
+ * order — the order a single record's read has always had.
1531
+ */
1532
+ elementsByRecord(table, ids, map) {
1533
+ const byRecord = /* @__PURE__ */ new Map();
1534
+ const rows2 = this.exec.all(`WITH ids AS MATERIALIZED ${rowsTable(["id"])}
1535
+ SELECT * FROM ${table} WHERE record_id IN (SELECT id FROM ids)
1536
+ ORDER BY ${table === "associations" ? ASSOCIATION_KEY : PERMISSION_KEY}`, [JSON.stringify(ids.map((id) => [id]))]);
1537
+ for (const row of rows2) {
1538
+ const id = row.record_id;
1539
+ const list = byRecord.get(id);
1540
+ if (list)
1541
+ list.push(map(row));
1542
+ else
1543
+ byRecord.set(id, [map(row)]);
1544
+ }
1545
+ return byRecord;
1449
1546
  }
1450
1547
  };
1548
+ var ASSOCIATION_KEY = "record_id, kind, label, file_id, related_scope, related_id, related_ns, related_stack";
1549
+ var PERMISSION_KEY = "record_id, kind, label, grantee_kind, grantee_id, role";
1550
+ var addsOf = (edits) => edits.flatMap((e) => e.op === "add" ? [e.association] : []);
1451
1551
 
1452
1552
  // ../sqlite-shared/dist/record-adapter.js
1453
1553
  var SQLITE_RECORD_CAPABILITIES = {
1454
1554
  filter: {
1455
1555
  content: "path",
1456
1556
  contentPresent: true,
1457
- // Inside SQLite's limits on expression depth and compound selects; see
1458
- // docs/spec/adapters.md § Adapter capabilities.
1459
- contentByType: 64,
1557
+ contentByType: true,
1460
1558
  search: true
1461
1559
  },
1462
1560
  sort: {
1463
1561
  fields: ["createdAt", "updatedAt", "version"],
1464
1562
  contentField: true,
1465
- contentFieldByType: 64
1563
+ contentFieldByType: true
1466
1564
  },
1467
1565
  limits: {
1468
1566
  attachmentBytes: null,
@@ -1488,8 +1586,14 @@ var SharedSqlRecordAdapter = class {
1488
1586
  getRecord(id) {
1489
1587
  return this.record.getRecord(id);
1490
1588
  }
1491
- mutateRecord(id, changes, opts) {
1492
- return this.record.mutateRecord(id, changes, opts);
1589
+ patchContent(id, patch, opts) {
1590
+ return this.record.patchContent(id, patch, opts);
1591
+ }
1592
+ setParent(id, parentId, opts) {
1593
+ return this.record.setParent(id, parentId, opts);
1594
+ }
1595
+ setUnlisted(id, unlisted, opts) {
1596
+ return this.record.setUnlisted(id, unlisted, opts);
1493
1597
  }
1494
1598
  deleteRecord(id, opts) {
1495
1599
  return this.record.deleteRecord(id, opts);
@@ -1506,8 +1610,11 @@ var SharedSqlRecordAdapter = class {
1506
1610
  queryRecords(query) {
1507
1611
  return this.record.queryRecords(query);
1508
1612
  }
1509
- deleteUnreferencedAttachmentRecords(fileId, metadataTypeIds) {
1510
- return this.record.deleteUnreferencedAttachmentRecords(fileId, metadataTypeIds);
1613
+ queryReadable(query, reach) {
1614
+ return this.record.queryReadable(query, reach);
1615
+ }
1616
+ deleteUnreferencedAttachmentRecords(fileId, metadataBaseId) {
1617
+ return this.record.deleteUnreferencedAttachmentRecords(fileId, metadataBaseId);
1511
1618
  }
1512
1619
  // -------------------------------------------------------
1513
1620
  // Versions
@@ -1540,10 +1647,19 @@ var SharedSqlRecordAdapter = class {
1540
1647
  return this.record.listTypes();
1541
1648
  }
1542
1649
  // -------------------------------------------------------
1543
- // Associations
1650
+ // Associations and permissions
1544
1651
  // -------------------------------------------------------
1545
- amendAssociations(recordId, changes, opts) {
1546
- return this.record.amendAssociations(recordId, changes, opts);
1652
+ editAssociations(id, edits, opts) {
1653
+ return this.record.editAssociations(id, edits, opts);
1654
+ }
1655
+ setAssociations(id, associations, opts) {
1656
+ return this.record.setAssociations(id, associations, opts);
1657
+ }
1658
+ editAccess(id, edits, opts) {
1659
+ return this.record.editAccess(id, edits, opts);
1660
+ }
1661
+ setAccess(id, permissions, opts) {
1662
+ return this.record.setAccess(id, permissions, opts);
1547
1663
  }
1548
1664
  };
1549
1665
 
@@ -1564,8 +1680,8 @@ var DurableObjectSqliteExecutor = class {
1564
1680
  return cursor.rowsWritten;
1565
1681
  }
1566
1682
  get(sql, params = []) {
1567
- const rows = this.sql.exec(sql, ...params).toArray();
1568
- return rows[0];
1683
+ const rows2 = this.sql.exec(sql, ...params).toArray();
1684
+ return rows2[0];
1569
1685
  }
1570
1686
  all(sql, params = []) {
1571
1687
  return this.sql.exec(sql, ...params).toArray();