@feasibleone/blong-gogo 1.28.0 → 1.29.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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.29.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.28.0...blong-gogo-v1.29.0) (2026-08-19)
4
+
5
+
6
+ ### Features
7
+
8
+ * blong-access UI ([999de20](https://github.com/feasibleone/blong/commit/999de20b5f8e36979787bed71695de45a4006f5f))
9
+
3
10
  ## [1.28.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.27.0...blong-gogo-v1.28.0) (2026-08-19)
4
11
 
5
12
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feasibleone/blong-gogo",
3
- "version": "1.28.0",
3
+ "version": "1.29.0",
4
4
  "repository": {
5
5
  "url": "git+https://github.com/feasibleone/blong.git"
6
6
  },
@@ -195,14 +195,22 @@ export class AdapterBase<T, C extends IContext> implements AdapterHandlerContext
195
195
  return null;
196
196
  }
197
197
 
198
+ namespaces(): (string | RegExp)[] {
199
+ const namespace = this.config.namespace;
200
+ return ([] as (string | RegExp)[]).concat(
201
+ namespace
202
+ ? Array.isArray(namespace) || typeof namespace === 'string'
203
+ ? namespace
204
+ : Object.keys(namespace)
205
+ : this.config.imports || this.config.id.replace(/\./g, '-'),
206
+ );
207
+ }
208
+
198
209
  handles(name: string): boolean {
199
210
  if (reserved.includes(name)) return true;
200
- const id = this.config.id.replace(/\./g, '-');
201
- return ([] as (string | RegExp)[])
202
- .concat(this.config.namespace || this.config.imports || id)
203
- .some(namespace =>
204
- typeof namespace === 'string' ? name.startsWith(namespace) : namespace.test(name),
205
- );
211
+ return this.namespaces().some(namespace =>
212
+ typeof namespace === 'string' ? name.startsWith(namespace) : namespace.test(name),
213
+ );
206
214
  }
207
215
 
208
216
  methodPath(methodName: string): string {
@@ -297,10 +305,7 @@ export class AdapterBase<T, C extends IContext> implements AdapterHandlerContext
297
305
  }
298
306
 
299
307
  forNamespaces<R>(reducer: (prev: R, current: unknown) => R, initial: R): R {
300
- const id = this.config.id.replace(/\./g, '-');
301
- return ([] as (string | RegExp)[])
302
- .concat(this.config.namespace || this.config.imports || id)
303
- .reduce(reducer.bind(this), initial);
308
+ return this.namespaces().reduce(reducer.bind(this), initial);
304
309
  }
305
310
 
306
311
  async start(): Promise<unknown> {
@@ -390,12 +395,34 @@ export default async function adapter<T, C extends IContext>(
390
395
 
391
396
  const base = new AdapterBase<T, C>(api, configBase, activationNames);
392
397
 
393
- const result = handlers!({utError, remote, type, schema: registry.objectSchema, manifest: api.manifest});
398
+ const result = handlers!({
399
+ utError,
400
+ remote,
401
+ type,
402
+ schema: registry.objectSchema,
403
+ manifest: api.manifest,
404
+ });
394
405
  let current = result;
395
406
  while (current.extends) {
396
407
  const parent = await (typeof current.extends === 'string'
397
- ? adapterFactory(current.extends)!({utError, remote, rpc, local, registry, schema, manifest: api.manifest})
398
- : current.extends({utError, remote, rpc, local, registry, schema, manifest: api.manifest}));
408
+ ? adapterFactory(current.extends)!({
409
+ utError,
410
+ remote,
411
+ rpc,
412
+ local,
413
+ registry,
414
+ schema,
415
+ manifest: api.manifest,
416
+ })
417
+ : current.extends({
418
+ utError,
419
+ remote,
420
+ rpc,
421
+ local,
422
+ registry,
423
+ schema,
424
+ manifest: api.manifest,
425
+ }));
399
426
  Object.setPrototypeOf(current, parent);
400
427
  current = parent;
401
428
  }
@@ -40,6 +40,44 @@ export interface ITableConstraints {
40
40
  >;
41
41
  }
42
42
 
43
+ export interface IEdgeBinding {
44
+ /**
45
+ * The `core_triple` predicate of the edge, e.g. `hasRole`.
46
+ */
47
+ predicate: string;
48
+ /**
49
+ * The entity table holding the edge's object rows (e.g. `access_role`).
50
+ * The edges themselves live in `core_triple` (`subjectId -predicate->
51
+ * objectId`), keyed by the resource id.
52
+ */
53
+ table: string;
54
+ /**
55
+ * The detail object name — the sibling array key returned on `get` and
56
+ * accepted on `add`/`edit` (defaults to the predicate's object segment,
57
+ * e.g. `role` for `hasRole`).
58
+ */
59
+ object?: string;
60
+ /**
61
+ * The object table's PK column (defaults to `${object}Id`).
62
+ */
63
+ objectKey?: string;
64
+ /**
65
+ * The object display-name field joined from `core_resource.resourceName`
66
+ * (defaults to `${object}Name`).
67
+ */
68
+ nameField?: string;
69
+ /**
70
+ * When true, only rows with `granted !== false` are kept when syncing
71
+ * edges on `add`/`edit` (the model pivot submits a `granted` boolean).
72
+ */
73
+ granted?: boolean;
74
+ /**
75
+ * When true, `remove` also deletes the reverse edges (`objectId` of this
76
+ * subject) pointing at this subject from other entities.
77
+ */
78
+ reverse?: boolean;
79
+ }
80
+
43
81
  export interface ISchemaTable {
44
82
  /**
45
83
  * The TypeBox `TObject` for the table. Optional when the definition is
@@ -48,6 +86,24 @@ export interface ISchemaTable {
48
86
  */
49
87
  definition?: TObject;
50
88
  order?: number;
89
+ /**
90
+ * Marks the table as resource-backed: its PK is a FK to
91
+ * `core.resource.resourceId` and its display name lives in
92
+ * `core_resource.resourceName`. When true the generic CRUD:
93
+ * - `add` generates a server-side PK + `core_resource` row for a missing
94
+ * not-null id (and when the PK carries a `uuid`/`ulid` default marker);
95
+ * - `find`/`get` join `resourceName` as `${object}Name`;
96
+ * - `edit` renames `core_resource.resourceName` from `${object}Name`;
97
+ * - `remove` deletes the `core_resource` row (and the declared `edges`).
98
+ */
99
+ resource?: boolean;
100
+ /**
101
+ * Declarative graph-edge master-detail bindings (`core_triple` edges such
102
+ * as `hasRole` / `hasCapability`). `get` attaches the edge rows as a
103
+ * sibling array; `add`/`edit` diff-sync the edges (plus one
104
+ * `access_pathRefresh`); `remove` cleans them.
105
+ */
106
+ edges?: IEdgeBinding[];
51
107
  /**
52
108
  * Optional per-table dropdown binding override used by the auto-bound
53
109
  * `{subject}.dropdown.list` handler (see the knex adapter `exec()`).
@@ -1,4 +1,4 @@
1
- import {withProgress} from '@feasibleone/blong-lib';
1
+ import {ulid, withProgress} from '@feasibleone/blong-lib';
2
2
  import {
3
3
  adapter,
4
4
  type Adapter,
@@ -15,8 +15,8 @@ import {type TFunction, type TObject} from 'typebox';
15
15
  import {v4} from 'uuid';
16
16
  import yaml from 'yaml';
17
17
  import {methodParts} from '../../lib.ts';
18
- import {ensureDatabase} from '../schema/knex/database.ts';
19
18
  import {
19
+ binaryToStr,
20
20
  discoverBinaryColumns,
21
21
  isBinaryColumn,
22
22
  prepareInputParams,
@@ -24,6 +24,7 @@ import {
24
24
  prepareResultRows,
25
25
  strToBinary,
26
26
  } from '../schema/knex/binary.ts';
27
+ import {ensureDatabase} from '../schema/knex/database.ts';
27
28
  import {wrapKnex} from '../schema/knex/json.ts';
28
29
  import {
29
30
  bindSyntheticHandlers,
@@ -37,6 +38,7 @@ import {
37
38
  } from '../schema/knex/schemaTable.ts';
38
39
  import {
39
40
  type IConfig,
41
+ type IEdgeBinding,
40
42
  type IKnexConfig,
41
43
  type ISchemaTable,
42
44
  type ITableConstraints,
@@ -152,9 +154,12 @@ export default adapter<IConfig>(({utError, schema: objectSchema}) => {
152
154
  }
153
155
  } catch (error) {
154
156
  // Warn-and-continue: schema sync will surface the real error.
155
- this.log?.warn?.({
156
- err: (error as {message?: string}).message ?? String(error),
157
- }, 'could not auto-create database will continue');
157
+ this.log?.warn?.(
158
+ {
159
+ err: (error as {message?: string}).message ?? String(error),
160
+ },
161
+ 'could not auto-create database — will continue',
162
+ );
158
163
  }
159
164
  }
160
165
  this.config.context = {
@@ -367,6 +372,7 @@ export default adapter<IConfig>(({utError, schema: objectSchema}) => {
367
372
  const {select: _select, ...where} = params;
368
373
  const qb = this.config.context.queryBuilder!;
369
374
  const binaryCols = getBinaryCols(this.config.context);
375
+ const opts = tableOptions(objectSchema, this.config, subject, object);
370
376
  let query = qb(table);
371
377
  for (const [key, val] of Object.entries(where)) {
372
378
  if (isBinaryColumn(binaryCols, table, key) && typeof val === 'string') {
@@ -376,19 +382,34 @@ export default adapter<IConfig>(({utError, schema: objectSchema}) => {
376
382
  }
377
383
  }
378
384
  const row = (await query.first()) as Record<string, unknown> | undefined;
379
- const result: Record<string, unknown> = {
380
- [object]: prepareResultRow(row, binaryCols, table),
381
- };
382
- // Master-detail: return each FK-constrained detail table's
383
- // rows as sibling arrays (e.g. `line`, `payment`) so the
384
- // Open form can render them alongside the master record.
385
385
  const keyName =
386
386
  (
387
387
  objectSchema[subject]?.[object] as
388
388
  | {constraints?: {primaryKey?: string}}
389
389
  | undefined
390
390
  )?.constraints?.primaryKey ?? `${object}Id`;
391
+ // Capture the raw PK **before** `prepareResultRow` mutates
392
+ // the row in place (Buffers → base64 strings). The edge
393
+ // attachment needs the binary master key.
391
394
  const masterKey = row?.[keyName];
395
+ const result: Record<string, unknown> = {
396
+ [object]: prepareResultRow(row, binaryCols, table),
397
+ };
398
+ const masterRow = result[object] as Record<string, unknown> | undefined;
399
+ // Resource-backed: join the display name from
400
+ // `core_resource.resourceName` as `${object}Name`.
401
+ if (opts.resource && masterRow && typeof masterRow[keyName] === 'string') {
402
+ const [joined] = await joinResourceNames(
403
+ qb,
404
+ [masterRow],
405
+ keyName,
406
+ `${object}Name`,
407
+ );
408
+ result[object] = joined;
409
+ }
410
+ // Master-detail: return each FK-constrained detail table's
411
+ // rows as sibling arrays (e.g. `line`, `payment`) so the
412
+ // Open form can render them alongside the master record.
392
413
  if (masterKey !== undefined) {
393
414
  for (const detail of detailTables(subject, object, keyName)) {
394
415
  const detailBinaryCols = getBinaryCols(this.config.context);
@@ -401,6 +422,18 @@ export default adapter<IConfig>(({utError, schema: objectSchema}) => {
401
422
  detail.table,
402
423
  );
403
424
  }
425
+ // Graph-edge master-detail (declarative `edges`) — the
426
+ // rows live in `core_triple` keyed by the resource id.
427
+ // Only binary (resource-backed) master keys participate.
428
+ if (Buffer.isBuffer(masterKey)) {
429
+ for (const binding of opts.edges) {
430
+ if (!binding.table) continue; // reverse-only cleanup binding
431
+ const detailObject =
432
+ binding.object ??
433
+ binding.predicate.replace(/^has/, '').toLowerCase();
434
+ result[detailObject] = await attachEdgeRows(qb, masterKey, binding);
435
+ }
436
+ }
404
437
  }
405
438
  return result;
406
439
  }
@@ -418,6 +451,7 @@ export default adapter<IConfig>(({utError, schema: objectSchema}) => {
418
451
  } = params;
419
452
  const qb = this.config.context.queryBuilder!;
420
453
  const binaryCols = getBinaryCols(this.config.context);
454
+ const opts = tableOptions(objectSchema, this.config, subject, object);
421
455
  let query = qb(table);
422
456
  for (const [key, val] of Object.entries({...filterBy, ...where})) {
423
457
  if (isBinaryColumn(binaryCols, table, key) && typeof val === 'string') {
@@ -443,7 +477,13 @@ export default adapter<IConfig>(({utError, schema: objectSchema}) => {
443
477
  if (limit) query = query.limit(limit);
444
478
  if (offset) query = query.offset(offset);
445
479
  const rows = (await query.select(select)) as Record<string, unknown>[];
446
- return prepareResultRows(rows, binaryCols, table);
480
+ const prepared = prepareResultRows(rows, binaryCols, table);
481
+ // Resource-backed: join the display name from
482
+ // `core_resource.resourceName` as `${object}Name`.
483
+ if (opts.resource) {
484
+ return joinResourceNames(qb, prepared, `${object}Id`, `${object}Name`);
485
+ }
486
+ return prepared;
447
487
  }
448
488
  case 'add': {
449
489
  const {
@@ -452,9 +492,17 @@ export default adapter<IConfig>(({utError, schema: objectSchema}) => {
452
492
  resourceName,
453
493
  ...rest
454
494
  } = params;
455
- const definition = objectSchema[subject]?.[object] as unknown as
456
- | Record<string, unknown>
457
- | undefined;
495
+ // Resolve the table definition from the declarative
496
+ // `schema.tables` entry first (falling back to the realm
497
+ // `objectSchema`), so namespace-scoped methods (e.g.
498
+ // `sql.person.add`) find their properties/FK constraints
499
+ // when the definition lives in the adapter table config.
500
+ const definition = resolveTableSpec(
501
+ objectSchema,
502
+ this.config.schema?.tables?.[`${subject}.${object}`],
503
+ subject,
504
+ object,
505
+ ).definition as unknown as Record<string, unknown> | undefined;
458
506
  const properties = definition?.properties as
459
507
  | Record<string, IColumnSchema>
460
508
  | undefined;
@@ -465,52 +513,95 @@ export default adapter<IConfig>(({utError, schema: objectSchema}) => {
465
513
  )?.foreign;
466
514
  const qb = this.config.context.queryBuilder!;
467
515
  const binaryCols = getBinaryCols(this.config.context);
516
+ const opts = tableOptions(objectSchema, this.config, subject, object);
468
517
  const cols = columns as Record<string, unknown>;
469
- // Generate real UUIDs for type.uuid() columns that have the
470
- // literal default value 'uuid'. Track generated UUIDs so we
471
- // can select back the inserted row by UUID.
518
+ // Resource-backed: `${object}Name` is a virtual display field
519
+ // (the name lives in `core_resource.resourceName`) capture
520
+ // it for the resource row and exclude it from the table insert.
521
+ const nameColValue = opts.resource
522
+ ? (cols[`${object}Name`] as string | undefined)
523
+ : undefined;
524
+ if (opts.resource) {
525
+ delete cols[`${object}Name`];
526
+ }
527
+ // Generate real PKs server-side for id columns that carry a
528
+ // 'uuid' / 'ulid' default marker (`type.uuid()` / `type.ulid()`
529
+ // are submitted as the literal placeholder) OR whose not-null
530
+ // id the caller did not supply on a resource-backed table
531
+ // (PK is a FK to `core.resource`, e.g. `type.uidNotNull()`).
532
+ // Track the generated key so the inserted row can be selected
533
+ // back by it.
472
534
  let generatedKey: string | undefined;
535
+ // Ensure a `core_type` row exists (mirrors core.resource.ensure)
536
+ // so resource-backed inserts always get a type.
537
+ const ensureType = async (typeAlias: string): Promise<number | undefined> => {
538
+ const existing = await qb('core_type').where({typeAlias}).first('typeId');
539
+ if (existing) return existing.typeId as number;
540
+ await qb('core_type').insert({typeAlias}).onConflict().ignore();
541
+ const inserted = await qb('core_type').where({typeAlias}).first('typeId');
542
+ return inserted ? (inserted.typeId as number) : undefined;
543
+ };
544
+ // Create the matching `core_resource` row for a generated
545
+ // resource-backed PK. The name is the entity's display label
546
+ // in `{subject}.dropdown.list`.
547
+ const ensureResourceRow = async (
548
+ idStr: string,
549
+ resourceKeyCol: string,
550
+ ): Promise<void> => {
551
+ const typeAlias = `${subject}.${object}`;
552
+ const typeId = await ensureType(typeAlias);
553
+ if (!typeId) return;
554
+ const name =
555
+ (typeof resourceName === 'string' && resourceName) ||
556
+ nameColValue ||
557
+ `${subject}.${object}.${resourceKeyCol}`;
558
+ await qb('core_resource')
559
+ .insert({
560
+ resourceId: strToBinary(idStr),
561
+ resourceName: name,
562
+ typeId,
563
+ })
564
+ .onConflict()
565
+ .ignore();
566
+ };
567
+ // 1) Literal 'uuid' / 'ulid' default markers on id columns.
473
568
  for (const colName of Object.keys(cols)) {
474
- if (cols[colName] !== 'uuid') continue;
569
+ if (cols[colName] !== 'uuid' && cols[colName] !== 'ulid') continue;
475
570
  const prop = properties?.[colName];
476
- if (!prop || propDefault(prop) !== 'uuid') continue;
477
- const uuidStr = crypto.randomUUID();
478
- cols[colName] = strToBinary(uuidStr);
479
- generatedKey = uuidStr;
480
- // If this PK is also a FK to core.resource, create the
481
- // corresponding core_resource row.
571
+ const marker = cols[colName] as 'uuid' | 'ulid';
572
+ if (!prop || propDefault(prop) !== marker) continue;
573
+ const idStr = marker === 'ulid' ? ulid() : crypto.randomUUID();
574
+ cols[colName] = strToBinary(idStr);
575
+ generatedKey = idStr;
482
576
  if (foreignKeys?.[colName] === 'core.resource.resourceId') {
483
- const typeAlias = `${subject}.${object}`;
484
- const typeRow = await qb('core_type')
485
- .where({typeAlias})
486
- .first('typeId');
487
- if (typeRow) {
488
- // Use a meaningful resourceName when available:
489
- // an explicit `resourceName` param, else the
490
- // `${object}Name` column value, else the synthetic
491
- // name. The name is the entity's display label in
492
- // `{subject}.dropdown.list`.
493
- const name =
494
- (typeof resourceName === 'string' && resourceName) ||
495
- (typeof cols[`${object}Name`] === 'string'
496
- ? (cols[`${object}Name`] as string)
497
- : undefined) ||
498
- `${subject}.${object}.${colName}`;
499
- await qb('core_resource')
500
- .insert({
501
- resourceId: strToBinary(uuidStr),
502
- resourceName: name,
503
- typeId: typeRow.typeId,
504
- })
505
- .onConflict()
506
- .ignore();
507
- }
577
+ await ensureResourceRow(idStr, colName);
578
+ }
579
+ }
580
+ // 2) Resource-backed not-null PK (no default marker, e.g.
581
+ // `type.uidNotNull()`) with no key supplied by the caller.
582
+ if (
583
+ !generatedKey &&
584
+ cols[keyName] == null &&
585
+ foreignKeys?.[keyName] === 'core.resource.resourceId'
586
+ ) {
587
+ const pkProp = properties?.[keyName];
588
+ if (!pkProp || !propDefault(pkProp)) {
589
+ generatedKey = crypto.randomUUID();
590
+ cols[keyName] = strToBinary(generatedKey);
591
+ await ensureResourceRow(generatedKey, keyName);
508
592
  }
509
593
  }
510
594
  // Convert any string values for binary columns to Buffer
511
595
  const insertCols = prepareInputParams(cols, binaryCols, table);
512
596
  const inserted = await qb(table).insert(insertCols);
513
- const masterKey = generatedKey ? strToBinary(generatedKey) : inserted[0];
597
+ // Select the inserted row back by the PK. Prefer the explicit
598
+ // key value (post-conversion) when the caller supplied one
599
+ // (e.g. a real ULID/UUID string) — `insertId` only works for
600
+ // auto-increment PKs and is 0 for a binary-PK table.
601
+ const masterKey =
602
+ generatedKey
603
+ ? strToBinary(generatedKey)
604
+ : (insertCols[keyName] as Buffer | string | undefined) ?? inserted[0];
514
605
  const row = (await qb(table)
515
606
  .where({[keyName]: masterKey})
516
607
  .first()) as Record<string, unknown>;
@@ -548,16 +639,69 @@ export default adapter<IConfig>(({utError, schema: objectSchema}) => {
548
639
  detail.table,
549
640
  );
550
641
  }
642
+ // Graph-edge master-detail: persist each declared edge from
643
+ // its sibling array (filtering `granted !== false` when the
644
+ // binding uses the pivot convention) and attach the rows.
645
+ if (Buffer.isBuffer(masterKey)) {
646
+ const opts = tableOptions(objectSchema, this.config, subject, object);
647
+ for (const binding of opts.edges) {
648
+ if (!binding.table) continue; // reverse-only cleanup binding
649
+ const detailObject =
650
+ binding.object ??
651
+ binding.predicate.replace(/^has/, '').toLowerCase();
652
+ const edgeRows = Array.isArray(rest[detailObject])
653
+ ? (rest[detailObject] as Array<Record<string, unknown>>)
654
+ : [];
655
+ const objectKey = binding.objectKey ?? `${detailObject}Id`;
656
+ const ids = edgeRows
657
+ .filter(r => (binding.granted ? r.granted !== false : true))
658
+ .map(r => {
659
+ const id = r[objectKey];
660
+ return typeof id === 'string'
661
+ ? strToBinary(id).toString('hex')
662
+ : undefined;
663
+ })
664
+ .filter((x): x is string => !!x);
665
+ if (ids.length) {
666
+ await syncGraphEdges(qb, masterKey, binding.predicate, ids);
667
+ }
668
+ result[detailObject] = await attachEdgeRows(qb, masterKey, binding);
669
+ }
670
+ }
671
+ // Resource-backed: join the display name onto the master so
672
+ // the caller sees `${object}Name` in the created row.
673
+ if (opts.resource && Buffer.isBuffer(masterKey)) {
674
+ const masterRow = result[object] as Record<string, unknown> | undefined;
675
+ if (masterRow && typeof masterRow[`${object}Id`] === 'string') {
676
+ const [joined] = await joinResourceNames(
677
+ qb,
678
+ [masterRow],
679
+ `${object}Id`,
680
+ `${object}Name`,
681
+ );
682
+ result[object] = joined;
683
+ }
684
+ }
551
685
  return result;
552
686
  }
553
687
  case 'edit': {
554
688
  const {key: keyName = `${object}Id`, [object]: columns, ...rest} = params;
555
689
  const qb = this.config.context.queryBuilder!;
556
690
  const binaryCols = getBinaryCols(this.config.context);
691
+ const opts = tableOptions(objectSchema, this.config, subject, object);
557
692
  const cols = columns as Record<string, unknown>;
558
693
  const {[keyName]: key, ...update} = cols as Record<string, unknown>;
559
694
  const isBinaryKey =
560
695
  isBinaryColumn(binaryCols, table, keyName) && typeof key === 'string';
696
+ // Resource-backed: `${object}Name` is a virtual field — the
697
+ // display name lives in `core_resource.resourceName`, so
698
+ // rename that row instead of updating a table column.
699
+ const resourceName = opts.resource
700
+ ? (update[`${object}Name`] as string | undefined)
701
+ : undefined;
702
+ if (opts.resource) {
703
+ delete update[`${object}Name`];
704
+ }
561
705
  // Convert any string values for binary columns to Buffer (the
562
706
  // form round-trips them as base64 strings returned by `get`).
563
707
  const preparedUpdate = prepareInputParams(update, binaryCols, table);
@@ -569,6 +713,16 @@ export default adapter<IConfig>(({utError, schema: objectSchema}) => {
569
713
  .where({[keyName]: key})
570
714
  .update(preparedUpdate);
571
715
  }
716
+ if (
717
+ opts.resource &&
718
+ isBinaryKey &&
719
+ typeof resourceName === 'string' &&
720
+ resourceName
721
+ ) {
722
+ await qb('core_resource')
723
+ .where('resourceId', strToBinary(key))
724
+ .update({resourceName});
725
+ }
572
726
  // Select back with Buffer → base64 conversion
573
727
  let editQuery = qb(table);
574
728
  if (isBinaryKey) {
@@ -603,7 +757,39 @@ export default adapter<IConfig>(({utError, schema: objectSchema}) => {
603
757
  );
604
758
  }
605
759
  }
606
- return {[object]: prepareResultRow(editRow, binaryCols, table)};
760
+ const result: Record<string, unknown> = {
761
+ [object]: prepareResultRow(editRow, binaryCols, table),
762
+ };
763
+ // Graph-edge master-detail: bring each declared edge in line
764
+ // with the submitted sibling array (when present) and re-attach
765
+ // the fresh edge rows to the result.
766
+ if (isBinaryKey) {
767
+ const masterKeyBuf = strToBinary(key);
768
+ for (const binding of opts.edges) {
769
+ if (!binding.table) continue; // reverse-only cleanup binding
770
+ const detailObject =
771
+ binding.object ??
772
+ binding.predicate.replace(/^has/, '').toLowerCase();
773
+ const edgeRows = Array.isArray(rest[detailObject])
774
+ ? (rest[detailObject] as Array<Record<string, unknown>>)
775
+ : undefined;
776
+ if (edgeRows !== undefined) {
777
+ const objectKey = binding.objectKey ?? `${detailObject}Id`;
778
+ const ids = edgeRows
779
+ .filter(r => (binding.granted ? r.granted !== false : true))
780
+ .map(r => {
781
+ const id = r[objectKey];
782
+ return typeof id === 'string'
783
+ ? strToBinary(id).toString('hex')
784
+ : undefined;
785
+ })
786
+ .filter((x): x is string => !!x);
787
+ await syncGraphEdges(qb, masterKeyBuf, binding.predicate, ids);
788
+ }
789
+ result[detailObject] = await attachEdgeRows(qb, masterKeyBuf, binding);
790
+ }
791
+ }
792
+ return result;
607
793
  }
608
794
  case 'remove': {
609
795
  const {key: keyName = `${object}Id`, [keyName]: key} = params;
@@ -611,28 +797,54 @@ export default adapter<IConfig>(({utError, schema: objectSchema}) => {
611
797
  throw this.error(_errors['knex.missingKey']({key: keyName}), $meta);
612
798
  }
613
799
  const binaryCols = getBinaryCols(this.config.context);
800
+ const opts = tableOptions(objectSchema, this.config, subject, object);
614
801
  const isBinaryKey =
615
802
  isBinaryColumn(binaryCols, table, keyName) && typeof key === 'string';
616
803
  const masterKey = isBinaryKey ? strToBinary(key as string) : key;
617
804
  if (!masterKey) {
618
805
  throw this.error(_errors['knex.missingKey']({key: keyName}), $meta);
619
806
  }
807
+ const qb = this.config.context.queryBuilder!;
620
808
  // Master-detail: delete each FK-constrained detail table's
621
809
  // rows for this master BEFORE deleting the master row, so a
622
810
  // non-cascading FK does not block the delete.
623
811
  for (const detail of detailTables(subject, object, keyName)) {
624
- await this.config.context.queryBuilder!(detail.table)
812
+ await qb(detail.table)
625
813
  .where({[detail.fkColumn]: masterKey})
626
814
  .del();
627
815
  }
628
- if (isBinaryKey) {
629
- return this.config.context.queryBuilder!(table)
630
- .where(keyName, masterKey)
631
- .del();
816
+ // Graph edges: delete the subject's own edges and (when the
817
+ // binding declares `reverse`) the edges pointing AT it.
818
+ if (Buffer.isBuffer(masterKey)) {
819
+ const subjectBuf = masterKey as Buffer;
820
+ for (const binding of opts.edges) {
821
+ await qb('core_triple')
822
+ .where('subjectId', subjectBuf)
823
+ .where('predicateName', binding.predicate)
824
+ .del();
825
+ if (binding.reverse) {
826
+ await qb('core_triple')
827
+ .where('objectId', subjectBuf)
828
+ .where('predicateName', binding.predicate)
829
+ .del();
830
+ }
831
+ }
832
+ if (opts.edges.length) {
833
+ await qb.raw('CALL access_pathRefresh()');
834
+ }
632
835
  }
633
- return this.config.context.queryBuilder!(table)
634
- .where({[keyName]: key})
635
- .del();
836
+ // Entity row first — its PK is a FK to `core_resource`, so
837
+ // the resource row must be deleted only after the entity row.
838
+ const removed = isBinaryKey
839
+ ? await qb(table).where(keyName, masterKey).del()
840
+ : await qb(table)
841
+ .where({[keyName]: key})
842
+ .del();
843
+ // Resource-backed: delete the `core_resource` row last.
844
+ if (Buffer.isBuffer(masterKey) && opts.resource) {
845
+ await qb('core_resource').where('resourceId', masterKey).del();
846
+ }
847
+ return removed;
636
848
  }
637
849
  case 'merge': {
638
850
  const {key = `${object}Id`, [object]: objectRows, resourceType} = params;
@@ -936,21 +1148,35 @@ async function processSeedAssets(
936
1148
 
937
1149
  export {attachHandlers, methodId, snakeToCamel};
938
1150
 
1151
+ /** Resolved per-table CRUD options (resource-backed + graph-edge bindings). */
1152
+ export interface IResolvedTableOptions {
1153
+ /** Whether the table is resource-backed (PK → `core.resource`). */
1154
+ resource: boolean;
1155
+ /** Declarative graph-edge master-detail bindings. */
1156
+ edges: IEdgeBinding[];
1157
+ }
1158
+
939
1159
  /**
940
1160
  * Resolve a `schema.tables` entry into its definition + dropdown override.
941
1161
  *
942
1162
  * A table entry is one of:
943
1163
  * - a plain order number → definition from `objectSchema[subject][object]`
944
- * - an `ISchemaTable` spec `{definition?, order?, dropdown?}` — definition
945
- * falls back to `objectSchema[subject][object]`
1164
+ * - an `ISchemaTable` spec `{definition?, order?, dropdown?, resource?, edges?}`
1165
+ * — definition falls back to `objectSchema[subject][object]`
946
1166
  * - a bare TypeBox `TObject`
947
1167
  */
948
1168
  function resolveTableSpec(
949
1169
  objectSchema: IObjectSchema,
950
- tableConfig: number | ISchemaTable | TObject,
1170
+ tableConfig: number | ISchemaTable | TObject | undefined,
951
1171
  subject: string,
952
1172
  object: string,
953
- ): {definition?: TObject; dropdown?: ISchemaTable['dropdown']} {
1173
+ ): {
1174
+ definition?: TObject;
1175
+ dropdown?: ISchemaTable['dropdown'];
1176
+ resource?: boolean;
1177
+ edges?: IEdgeBinding[];
1178
+ } {
1179
+ if (tableConfig === undefined) return {};
954
1180
  if (typeof tableConfig === 'number') {
955
1181
  return {definition: objectSchema[subject]?.[object]};
956
1182
  }
@@ -958,14 +1184,160 @@ function resolveTableSpec(
958
1184
  // An ISchemaTable spec — either with an explicit `definition`, or a
959
1185
  // partial spec (e.g. only `{order, dropdown}`) that falls back to the
960
1186
  // realm schema for its definition.
961
- if ('definition' in tableConfig || 'order' in tableConfig || 'dropdown' in tableConfig) {
1187
+ if (
1188
+ 'definition' in tableConfig ||
1189
+ 'order' in tableConfig ||
1190
+ 'dropdown' in tableConfig ||
1191
+ 'resource' in tableConfig ||
1192
+ 'edges' in tableConfig
1193
+ ) {
962
1194
  const spec = tableConfig as ISchemaTable;
963
1195
  return {
964
1196
  definition: spec.definition ?? objectSchema[subject]?.[object],
965
1197
  dropdown: spec.dropdown,
1198
+ resource: spec.resource,
1199
+ edges: spec.edges,
966
1200
  };
967
1201
  }
968
1202
  return {definition: tableConfig as TObject};
969
1203
  }
970
1204
  return {};
971
1205
  }
1206
+
1207
+ /**
1208
+ * Resolve the declarative CRUD options for `subject.object` from the schema
1209
+ * table config. Tables declared with `resource: true` (or an `edges` binding)
1210
+ * get the resource-backed + graph-edge generic behaviour.
1211
+ */
1212
+ function tableOptions(
1213
+ objectSchema: IObjectSchema,
1214
+ config: {schema?: {tables?: Record<string, number | ISchemaTable | TObject>}} | undefined,
1215
+ subject: string,
1216
+ object: string,
1217
+ ): IResolvedTableOptions {
1218
+ const tableConfig = config?.schema?.tables?.[`${subject}.${object}`];
1219
+ const spec =
1220
+ tableConfig !== undefined
1221
+ ? resolveTableSpec(objectSchema, tableConfig, subject, object)
1222
+ : {};
1223
+ const edges = spec.edges ?? [];
1224
+ return {
1225
+ resource: spec.resource === true || edges.length > 0,
1226
+ edges,
1227
+ };
1228
+ }
1229
+
1230
+ /**
1231
+ * Batched join of `core_resource.resourceName` onto rows as the given name
1232
+ * field (e.g. `roleName`). Row ids are base64/hex strings (post
1233
+ * `prepareResultRow`); rows that already carry the name field are left as-is.
1234
+ */
1235
+ async function joinResourceNames(
1236
+ qb: Knex,
1237
+ rows: Record<string, unknown>[],
1238
+ idField: string,
1239
+ nameField: string,
1240
+ ): Promise<Record<string, unknown>[]> {
1241
+ if (!rows.length) return rows;
1242
+ const ids = rows
1243
+ .map(r => (typeof r[idField] === 'string' ? (r[idField] as string) : undefined))
1244
+ .filter((x): x is string => !!x);
1245
+ if (!ids.length) return rows;
1246
+ const found = (await qb('core_resource')
1247
+ .whereIn(
1248
+ 'resourceId',
1249
+ ids.map(id => strToBinary(id)),
1250
+ )
1251
+ .select('resourceId', 'resourceName')) as Array<{resourceId: Buffer; resourceName: string}>;
1252
+ const names = new Map<string, string>();
1253
+ for (const r of found) names.set(r.resourceId.toString('hex'), r.resourceName);
1254
+ return rows.map(row => {
1255
+ if (row[nameField] !== undefined) return row;
1256
+ const id = typeof row[idField] === 'string' ? (row[idField] as string) : undefined;
1257
+ if (!id) return row;
1258
+ const name = names.get(strToBinary(id).toString('hex'));
1259
+ return name !== undefined ? ({...row, [nameField]: name} as Record<string, unknown>) : row;
1260
+ });
1261
+ }
1262
+
1263
+ /**
1264
+ * The hex object ids of a subject's graph edges (`core_triple`).
1265
+ */
1266
+ async function edgeObjectIds(qb: Knex, subjectId: Buffer, predicate: string): Promise<string[]> {
1267
+ const rows = (await qb('core_triple')
1268
+ .where('subjectId', subjectId)
1269
+ .where('predicateName', predicate)
1270
+ .select('objectId')) as Array<{objectId: Buffer}>;
1271
+ return rows.map(r => r.objectId.toString('hex'));
1272
+ }
1273
+
1274
+ /**
1275
+ * Bring `subjectId -predicate-> objectId` edges in line with `objectHexIds`
1276
+ * (add missing, delete stale, refresh `access_path` once).
1277
+ */
1278
+ async function syncGraphEdges(
1279
+ qb: Knex,
1280
+ subjectId: Buffer,
1281
+ predicate: string,
1282
+ objectHexIds: string[],
1283
+ ): Promise<void> {
1284
+ const existing = await edgeObjectIds(qb, subjectId, predicate);
1285
+ const existingSet = new Set(existing);
1286
+ const target = new Set(objectHexIds);
1287
+ const toAdd = objectHexIds.filter(id => !existingSet.has(id));
1288
+ const toRemove = existing.filter(id => !target.has(id));
1289
+ if (!toAdd.length && !toRemove.length) return;
1290
+ await qb.transaction(async trx => {
1291
+ if (toAdd.length) {
1292
+ await trx('core_triple').insert(
1293
+ toAdd.map(objectId => ({
1294
+ subjectId,
1295
+ predicateName: predicate,
1296
+ objectId: Buffer.from(objectId, 'hex'),
1297
+ })),
1298
+ );
1299
+ }
1300
+ if (toRemove.length) {
1301
+ await trx('core_triple')
1302
+ .where('subjectId', subjectId)
1303
+ .where('predicateName', predicate)
1304
+ .whereIn(
1305
+ 'objectId',
1306
+ toRemove.map(id => Buffer.from(id, 'hex')),
1307
+ )
1308
+ .del();
1309
+ }
1310
+ await trx.raw('CALL access_pathRefresh()');
1311
+ });
1312
+ }
1313
+
1314
+ /**
1315
+ * Attach a graph-edge binding's rows to a result as a sibling array: the edge
1316
+ * object rows joined with their resource name (and a `granted: true` marker
1317
+ * when the binding uses the `granted` pivot convention).
1318
+ */
1319
+ async function attachEdgeRows(
1320
+ qb: Knex,
1321
+ subjectId: Buffer,
1322
+ binding: IEdgeBinding,
1323
+ ): Promise<Record<string, unknown>[]> {
1324
+ const object = binding.object ?? binding.predicate.replace(/^has/, '').toLowerCase();
1325
+ const objectKey = binding.objectKey ?? `${object}Id`;
1326
+ const nameField = binding.nameField ?? `${object}Name`;
1327
+ const ids = await edgeObjectIds(qb, subjectId, binding.predicate);
1328
+ if (!ids.length) return [];
1329
+ const rows = (await qb(binding.table)
1330
+ .whereIn(
1331
+ objectKey,
1332
+ ids.map(hex => Buffer.from(hex, 'hex')),
1333
+ )
1334
+ .select('*')) as Record<string, unknown>[];
1335
+ // Prepare binary keys to base64 before joining names — joinResourceNames
1336
+ // expects string ids (base64/hex), not Buffers.
1337
+ const prepared = rows.map(row => ({
1338
+ ...row,
1339
+ [objectKey]: binaryToStr(row[objectKey] as Buffer),
1340
+ }));
1341
+ const named = await joinResourceNames(qb, prepared, objectKey, nameField);
1342
+ return named.map(row => (binding.granted ? {...row, granted: true} : row));
1343
+ }
@@ -17,8 +17,8 @@ import {realm} from '@feasibleone/blong';
17
17
  export default realm(() => ({
18
18
  url: import.meta.url,
19
19
  children: globalThis.window
20
- ? import.meta.glob(['./meta/**/*.ts', './browser/orchestrator/**/*.ts'])
21
- : ['./meta', './browser/orchestrator'],
20
+ ? import.meta.glob(['./meta/model/**/*.ts', './browser/orchestrator/**/*.ts'])
21
+ : ['./meta/model', './browser/orchestrator'],
22
22
  config: {
23
23
  default: {
24
24
  meta: true,