@kubun/store-graph 0.13.0 → 0.13.2

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/lib/api.d.ts CHANGED
@@ -2,7 +2,7 @@ import type { Adapter } from '@kubun/db-adapter';
2
2
  import { DocumentID, DocumentModelID } from '@kubun/id';
3
3
  import type { AggregateDocumentsParams, AggregateValues, DocumentModel, DocumentNode, GroupedAggregateDocumentsParams, GroupedAggregateValue } from '@kubun/protocol';
4
4
  import type { Kysely } from 'kysely';
5
- import type { AccessLevel, Catalog, ClusterDefinitionRow, ClusterModel, CreateDocumentParams, CreateGraphParams, GraphModel, GraphModelWithRecord, GraphTables, InsertCatalog, InsertClusterModel, InsertDocumentAttachment, InsertMutationLogEntry, ListDocumentsParams, MutationLogEntry, QueryDocumentsParams, QueryDocumentsResult, SaveDocumentParams, SearchDocumentResult, SearchDocumentsParams } from './tables.js';
5
+ import type { AccessLevel, Catalog, ClusterDefinitionRow, ClusterModel, CreateDocumentParams, CreateGraphParams, GraphModel, GraphModelWithRecord, GraphTables, InsertCatalog, InsertClusterModel, InsertMutationLogEntry, ListDocumentsParams, MutationLogEntry, QueryDocumentsParams, QueryDocumentsResult, SaveDocumentParams, SearchDocumentResult, SearchDocumentsParams } from './tables.js';
6
6
  /**
7
7
  * Persisted access rule shape as stored in `user_model_access_defaults`.
8
8
  */
@@ -83,7 +83,6 @@ export type GraphStoreAPI = {
83
83
  saveCluster(id: string, definition: unknown): Promise<void>;
84
84
  getCluster(id: string): Promise<ClusterDefinitionRow | undefined>;
85
85
  getClusters(ids: Array<string>): Promise<Record<string, unknown>>;
86
- addAttachments(attachments: Array<InsertDocumentAttachment>): Promise<void>;
87
86
  createSearchIndex(modelID: string, fields: Array<string>): Promise<void>;
88
87
  dropSearchIndex(modelID: string): Promise<void>;
89
88
  updateSearchEntry(modelID: string, documentID: string, data: Record<string, unknown>, fields: Array<string>): Promise<void>;
package/lib/api.js CHANGED
@@ -2,6 +2,7 @@ import { DocumentID, DocumentModelID } from '@kubun/id';
2
2
  import { serializeCursor } from './cursor.js';
3
3
  import { isMissingTableError, ModelNotDeployedError } from './errors.js';
4
4
  import { aggregateFieldName, applyAggregateSelections, applyDocumentFilter, applyDocumentOrderBy, applyGroupBySelections, applyPagination, groupKeyAlias, knownFieldColumn } from './query-builder.js';
5
+ import { resolveSearchableFields, SearchNotEnabledError } from './search.js';
5
6
  // --- Helper functions ---
6
7
  // SQL numeric results round-trip as strings on some adapters (Postgres `numeric`/`sum`),
7
8
  // so normalise to a JS number, treating NULL/empty as null.
@@ -342,6 +343,31 @@ export function createGraphStore(db, adapter) {
342
343
  effectiveOwners: owners
343
344
  };
344
345
  }
346
+ // --- Write-path search index maintenance ---
347
+ //
348
+ // Runs inline in the caller's write transaction (`db` here IS that
349
+ // transaction), so the FTS entry commits atomically with the document row.
350
+ // Gated on the `kubun_graph_search_models` registry: a model with no row is
351
+ // not searchable, so these are a no-op for it.
352
+ async function indexFieldsFor(model) {
353
+ const row = await db.selectFrom('kubun_graph_search_models').select('fields').where('model_id', '=', model).executeTakeFirst();
354
+ return row ? row.fields : null;
355
+ }
356
+ async function reindexDocument(model, documentID, data) {
357
+ const fields = await indexFieldsFor(model);
358
+ if (fields == null) return;
359
+ const fieldValues = {};
360
+ for (const field of fields){
361
+ const value = extractFieldValue(data, field);
362
+ if (value != null) fieldValues[field] = String(value);
363
+ }
364
+ await adapter.updateSearchEntry(db, model, documentID, fieldValues, fields);
365
+ }
366
+ async function unindexDocument(model, documentID) {
367
+ const fields = await indexFieldsFor(model);
368
+ if (fields == null) return;
369
+ await adapter.removeSearchEntry(db, model, documentID);
370
+ }
345
371
  const api = {
346
372
  // --- Document operations ---
347
373
  async getDocument (id) {
@@ -420,14 +446,23 @@ export function createGraphStore(db, adapter) {
420
446
  data: adapter.encodeJSON(params.data),
421
447
  unique: adapter.encodeBinary(params.unique)
422
448
  }).returningAll().executeTakeFirstOrThrow(()=>new Error(`createDocument failed to return row for ${id}`));
449
+ if (params.data) {
450
+ await reindexDocument(model, id, params.data);
451
+ }
423
452
  return documentNodeFromTable(adapter, doc);
424
453
  },
425
454
  async saveDocument (params) {
426
455
  const id = params.id.toString();
427
- const doc = await db.updateTable(`k_${params.id.model.toString()}`).set({
456
+ const model = params.id.model.toString();
457
+ const doc = await db.updateTable(`k_${model}`).set({
428
458
  data: params.data ? adapter.encodeJSON(params.data) : null,
429
459
  updated_at: adapter.encodeTimestamp(new Date())
430
460
  }).where('id', '=', id).returningAll().executeTakeFirstOrThrow(()=>new Error(`saveDocument: document not found for id=${id}`));
461
+ if (params.data) {
462
+ await reindexDocument(model, id, params.data);
463
+ } else {
464
+ await unindexDocument(model, id);
465
+ }
431
466
  return documentNodeFromTable(adapter, doc);
432
467
  },
433
468
  async queryDocuments (params) {
@@ -795,6 +830,65 @@ export function createGraphStore(db, adapter) {
795
830
  added.add(id);
796
831
  }));
797
832
  }
833
+ // Search indices: for every model named in the search config, create the
834
+ // `fts_<model>` index (once) and record its denormalized registry row (the
835
+ // single source of truth the write path and read guard consult) TOGETHER,
836
+ // only when the index does not already exist. The registry's `fields` must
837
+ // always equal the live FTS table's columns — the write path inserts
838
+ // `INSERT INTO fts_<model> (document_id, <fields...>)` using the registry
839
+ // list, so a registry row naming a column the table lacks turns every
840
+ // subsequent write into a "no such column" failure that rolls back the
841
+ // whole write transaction. Once an index exists, the write path
842
+ // (`createDocument`/`saveDocument`) keeps its entries in sync inline, so a
843
+ // re-deploy over an existing index is a pure no-op: it must not re-derive
844
+ // rows it already has, and it must not touch the registry (a re-deploy that
845
+ // changes a model's field set is unsupported — the table's columns are
846
+ // frozen at first creation — but it must never break writes by drifting the
847
+ // registry ahead of the table). Existence is checked via Kysely's
848
+ // dialect-agnostic introspection rather than an adapter/dialect string check
849
+ // (the adapter's `dialect` is a Kysely `Dialect` object, not a string).
850
+ const searchConfig = params.search ?? {};
851
+ const searchModelIDs = Object.keys(params.record).filter((id)=>id in searchConfig);
852
+ let existingFTSTables;
853
+ if (searchModelIDs.length > 0) {
854
+ const tables = await trx.introspection.getTables();
855
+ existingFTSTables = new Set(tables.map((t)=>t.name));
856
+ }
857
+ for (const [modelID, model] of Object.entries(params.record)){
858
+ if (!(modelID in searchConfig)) {
859
+ continue;
860
+ }
861
+ const fields = resolveSearchableFields(model.fieldsMeta, searchConfig[modelID]?.fields);
862
+ if (fields.length === 0) {
863
+ continue;
864
+ }
865
+ if (existingFTSTables?.has(`fts_${modelID}`)) {
866
+ continue;
867
+ }
868
+ await trx.insertInto('kubun_graph_search_models').values({
869
+ model_id: modelID,
870
+ fields: adapter.encodeJSON(fields)
871
+ }).onConflict((oc)=>oc.column('model_id').doUpdateSet((eb)=>({
872
+ fields: eb.ref('excluded.fields')
873
+ }))).execute();
874
+ await adapter.createSearchIndex(trx, {
875
+ modelID,
876
+ fields
877
+ });
878
+ const rows = await trx.selectFrom(`k_${modelID}`).selectAll().where('data', 'is not', null).execute();
879
+ for (const row of rows){
880
+ const node = documentNodeFromTable(adapter, row);
881
+ const data = node.data ?? {};
882
+ const fieldValues = {};
883
+ for (const field of fields){
884
+ const value = extractFieldValue(data, field);
885
+ if (value != null) {
886
+ fieldValues[field] = String(value);
887
+ }
888
+ }
889
+ await adapter.updateSearchEntry(trx, modelID, node.id, fieldValues, fields);
890
+ }
891
+ }
798
892
  for (const [clusterID, cluster] of Object.entries(params.clusters ?? {})){
799
893
  const entries = Object.entries(cluster.models).map(([modelID, index])=>({
800
894
  model_id: modelID,
@@ -860,10 +954,6 @@ export function createGraphStore(db, adapter) {
860
954
  }
861
955
  return result;
862
956
  },
863
- // --- Attachments ---
864
- async addAttachments (attachments) {
865
- await db.insertInto('kubun_graph_document_attachments').values(attachments).onConflict((oc)=>oc.doNothing()).execute();
866
- },
867
957
  // --- Search ---
868
958
  async createSearchIndex (modelID, fields) {
869
959
  await adapter.createSearchIndex(db, {
@@ -888,6 +978,17 @@ export function createGraphStore(db, adapter) {
888
978
  await adapter.removeSearchEntry(db, modelID, documentID);
889
979
  },
890
980
  async searchDocuments (params) {
981
+ // Guard against querying a model with no `kubun_graph_search_models`
982
+ // registry row: without this, a missing `fts_<model>` table surfaces
983
+ // as a raw adapter error (e.g. "no such table") instead of a clean,
984
+ // named one.
985
+ const configured = await db.selectFrom('kubun_graph_search_models').select('model_id').where('model_id', 'in', params.modelIDs).execute();
986
+ const configuredSet = new Set(configured.map((row)=>row.model_id));
987
+ for (const modelID of params.modelIDs){
988
+ if (!configuredSet.has(modelID)) {
989
+ throw new SearchNotEnabledError(modelID);
990
+ }
991
+ }
891
992
  const allResults = [];
892
993
  for (const modelID of params.modelIDs){
893
994
  const hits = await adapter.searchIndex(db, {
package/lib/index.d.ts CHANGED
@@ -4,6 +4,7 @@ export type { GraphStoreAPI, StoredAccessDefaultRow, StoredAccessRule } from './
4
4
  export { createGraphStore } from './api.js';
5
5
  export { graphStoreDefinition } from './definition.js';
6
6
  export { ModelNotDeployedError } from './errors.js';
7
+ export { resolveSearchableFields, SearchNotEnabledError } from './search.js';
7
8
  export declare const GRAPH_STORE: "graph";
8
9
  export declare function getGraphStore(provider: StoreProvider): Promise<GraphStoreAPI>;
9
- export type { AccessLevel, AccessPermissions, AddDocumentModelParams, Catalog, ClusterDefinitionRow, ClusterModel, ConnectionArguments, CreateDocumentParams, CreateGraphParams, CursorDocument, Document, DocumentAttachment, DocumentData, DocumentModelRow, DocumentParams, GraphModel, GraphModelWithRecord, GraphTables, InsertCatalog, InsertClusterModel, InsertDocument, InsertDocumentAttachment, InsertDocumentModel, InsertDocumentModelInterface, InsertMutationLogEntry, ListDocumentsParams, MutationLogEntry, PaginatedResult, PaginationParams, QueryDocumentsParams, QueryDocumentsResult, SaveDocumentParams, SearchConfig, SearchDocumentResult, SearchDocumentsParams, UpdateDocument, UpdateDocumentModel, ViewerReadAccess, } from './tables.js';
10
+ export type { AccessLevel, AccessPermissions, AddDocumentModelParams, Catalog, ClusterDefinitionRow, ClusterModel, ConnectionArguments, CreateDocumentParams, CreateGraphParams, CursorDocument, Document, DocumentData, DocumentModelRow, DocumentParams, GraphModel, GraphModelWithRecord, GraphTables, InsertCatalog, InsertClusterModel, InsertDocument, InsertDocumentModel, InsertDocumentModelInterface, InsertMutationLogEntry, ListDocumentsParams, MutationLogEntry, PaginatedResult, PaginationParams, QueryDocumentsParams, QueryDocumentsResult, SaveDocumentParams, SearchConfig, SearchDocumentResult, SearchDocumentsParams, UpdateDocument, UpdateDocumentModel, ViewerReadAccess, } from './tables.js';
package/lib/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export { createGraphStore } from './api.js';
2
2
  export { graphStoreDefinition } from './definition.js';
3
3
  export { ModelNotDeployedError } from './errors.js';
4
+ export { resolveSearchableFields, SearchNotEnabledError } from './search.js';
4
5
  export const GRAPH_STORE = 'graph';
5
6
  export function getGraphStore(provider) {
6
7
  return provider.getStore(GRAPH_STORE);
package/lib/migrations.js CHANGED
@@ -15,8 +15,6 @@
15
15
  const now = ctx.functions.now;
16
16
  const init = {
17
17
  async up (db) {
18
- // Document attachments
19
- await db.schema.createTable('kubun_graph_document_attachments').ifNotExists().addColumn('id', t.text, (col)=>col.notNull().primaryKey()).addColumn('data', t.binary, (col)=>col.notNull()).addColumn('created_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).execute();
20
18
  // Document models
21
19
  await db.schema.createTable('kubun_graph_document_models').ifNotExists().addColumn('id', t.text, (col)=>col.notNull().primaryKey()).addColumn('version', t.text, (col)=>col.notNull()).addColumn('name', t.text, (col)=>col.notNull()).addColumn('behavior', t.text, (col)=>col.notNull()).addColumn('unique_fields', t.json).addColumn('interfaces', t.json, (col)=>col.notNull()).addColumn('schema', t.json, (col)=>col.notNull()).addColumn('fields_meta', t.json, (col)=>col.notNull()).addColumn('created_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).execute();
22
20
  // Document model interfaces
@@ -26,6 +24,11 @@
26
24
  ]).execute();
27
25
  // Graph models
28
26
  await db.schema.createTable('kubun_graph_models').ifNotExists().addColumn('id', t.text, (col)=>col.notNull().primaryKey()).addColumn('name', t.text, (col)=>col.notNull()).addColumn('aliases', t.json, (col)=>col.defaultTo('{}').notNull()).addColumn('search', t.json).addColumn('extension_sdl', t.text).addColumn('plugin_config', t.json).addColumn('created_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).addColumn('updated_at', t.timestamp).execute();
27
+ // Search models registry: denormalized per-model FTS field list, written at
28
+ // deploy alongside the `fts_<model>` index. The write path and read guard read
29
+ // this table to learn which models are searchable and on which fields. `fields`
30
+ // mirrors the JSON column type used by `kubun_graph_models.search`.
31
+ await db.schema.createTable('kubun_graph_search_models').ifNotExists().addColumn('model_id', t.text, (col)=>col.notNull().primaryKey()).addColumn('fields', t.json, (col)=>col.notNull()).execute();
29
32
  // Graph document model links (junction: graph-model ↔ document-model)
30
33
  await db.schema.createTable('kubun_graph_document_model_links').ifNotExists().addColumn('graph_model_id', t.text, (col)=>col.notNull().references('kubun_graph_models.id')).addColumn('document_model_id', t.text, (col)=>col.notNull().references('kubun_graph_document_models.id')).addUniqueConstraint('kubun_graph_document_model_links_pkey', [
31
34
  'graph_model_id',
@@ -82,6 +85,7 @@
82
85
  }
83
86
  },
84
87
  async down (db) {
88
+ await db.schema.dropTable('kubun_graph_search_models').ifExists().execute();
85
89
  await db.schema.dropTable('kubun_graph_catalogs').ifExists().execute();
86
90
  await db.schema.dropTable('kubun_graph_clusters').ifExists().execute();
87
91
  await db.schema.dropTable('kubun_graph_cluster_models').ifExists().execute();
@@ -91,7 +95,6 @@
91
95
  await db.schema.dropTable('kubun_graph_models').ifExists().execute();
92
96
  await db.schema.dropTable('kubun_graph_document_model_interfaces').ifExists().execute();
93
97
  await db.schema.dropTable('kubun_graph_document_models').ifExists().execute();
94
- await db.schema.dropTable('kubun_graph_document_attachments').ifExists().execute();
95
98
  }
96
99
  };
97
100
  return {
@@ -0,0 +1,10 @@
1
+ import type { DocumentFieldsMeta } from '@kubun/protocol';
2
+ /**
3
+ * The field list to index for a model: an explicit `search` config's `fields`
4
+ * when present and non-empty, otherwise the model's field-level `searchable`
5
+ * flags (in declaration order). Empty when neither applies.
6
+ */
7
+ export declare function resolveSearchableFields(fieldsMeta: DocumentFieldsMeta | null | undefined, explicit?: Array<string>): Array<string>;
8
+ export declare class SearchNotEnabledError extends Error {
9
+ constructor(modelID: string);
10
+ }
package/lib/search.js ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The field list to index for a model: an explicit `search` config's `fields`
3
+ * when present and non-empty, otherwise the model's field-level `searchable`
4
+ * flags (in declaration order). Empty when neither applies.
5
+ */ export function resolveSearchableFields(fieldsMeta, explicit) {
6
+ if (explicit != null && explicit.length > 0) {
7
+ return explicit;
8
+ }
9
+ if (fieldsMeta == null) {
10
+ return [];
11
+ }
12
+ return Object.entries(fieldsMeta).filter(([, meta])=>meta?.searchable === true).map(([field])=>field);
13
+ }
14
+ export class SearchNotEnabledError extends Error {
15
+ constructor(modelID){
16
+ super(`Model ${modelID} does not have search enabled`);
17
+ this.name = 'SearchNotEnabledError';
18
+ }
19
+ }
package/lib/tables.d.ts CHANGED
@@ -18,13 +18,6 @@ export type DocumentTable<Data extends DocumentData = DocumentData> = {
18
18
  export type Document<Data extends DocumentData = DocumentData> = Selectable<DocumentTable<Data>>;
19
19
  export type InsertDocument<Data extends DocumentData = DocumentData> = Insertable<DocumentTable<Data>>;
20
20
  export type UpdateDocument<Data extends DocumentData = DocumentData> = Updateable<DocumentTable<Data>>;
21
- export type DocumentAttachmentTable = {
22
- id: string;
23
- data: Uint8Array;
24
- created_at: CreatedAtColumn;
25
- };
26
- export type DocumentAttachment = Selectable<DocumentAttachmentTable>;
27
- export type InsertDocumentAttachment = Insertable<DocumentAttachmentTable>;
28
21
  export type DocumentModelTable = {
29
22
  id: string;
30
23
  version: string;
@@ -62,6 +55,12 @@ export type GraphModelTable = {
62
55
  export type GraphModel = Selectable<GraphModelTable>;
63
56
  export type InsertGraphModel = Insertable<GraphModelTable>;
64
57
  export type UpdateGraphModel = Updateable<GraphModelTable>;
58
+ export type SearchModelTable = {
59
+ model_id: string;
60
+ fields: JSONValueColumn<Array<string>>;
61
+ };
62
+ export type SearchModelRow = Selectable<SearchModelTable>;
63
+ export type InsertSearchModel = Insertable<SearchModelTable>;
65
64
  export type GraphDocumentModelTable = {
66
65
  graph_model_id: string;
67
66
  document_model_id: string;
@@ -150,12 +149,12 @@ export type GraphTables = {
150
149
  kubun_graph_catalogs: CatalogTable;
151
150
  kubun_graph_cluster_models: ClusterModelTable;
152
151
  kubun_graph_clusters: ClusterDefinitionTable;
153
- kubun_graph_document_attachments: DocumentAttachmentTable;
154
152
  kubun_graph_document_models: DocumentModelTable;
155
153
  kubun_graph_document_model_interfaces: DocumentModelInterfaceTable;
156
154
  kubun_graph_document_model_links: GraphDocumentModelTable;
157
155
  kubun_graph_models: GraphModelTable;
158
156
  kubun_graph_mutation_log: MutationLogTable;
157
+ kubun_graph_search_models: SearchModelTable;
159
158
  kubun_graph_user_model_access_defaults: UserModelAccessDefaultTable;
160
159
  [key: `k_${string}`]: DocumentTable;
161
160
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubun/store-graph",
3
- "version": "0.13.0",
3
+ "version": "0.13.2",
4
4
  "keywords": [],
5
5
  "license": "see LICENSE.md",
6
6
  "sideEffects": false,
@@ -17,15 +17,15 @@
17
17
  "dependencies": {
18
18
  "@sozai/codec": "^0.4.0",
19
19
  "kysely": "^0.29.5",
20
- "@kubun/db": "^0.12.1",
21
- "@kubun/protocol": "^0.13.0",
22
- "@kubun/db-adapter": "^0.13.0",
23
- "@kubun/id": "^0.12.0"
20
+ "@kubun/id": "^0.13.0",
21
+ "@kubun/db-adapter": "^0.13.1",
22
+ "@kubun/db": "^0.13.0",
23
+ "@kubun/protocol": "^0.13.1"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@testcontainers/postgresql": "^12.1.0",
27
- "@kubun/db-better-sqlite": "^0.12.1",
28
- "@kubun/db-postgres": "^0.12.1"
27
+ "@kubun/db-postgres": "^0.13.0",
28
+ "@kubun/db-better-sqlite": "^0.13.0"
29
29
  },
30
30
  "publishConfig": {
31
31
  "access": "public"