@kubun/store-graph 0.13.1 → 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.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,
@@ -884,6 +978,17 @@ export function createGraphStore(db, adapter) {
884
978
  await adapter.removeSearchEntry(db, modelID, documentID);
885
979
  },
886
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
+ }
887
992
  const allResults = [];
888
993
  for (const modelID of params.modelIDs){
889
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
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
@@ -24,6 +24,11 @@
24
24
  ]).execute();
25
25
  // Graph models
26
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();
27
32
  // Graph document model links (junction: graph-model ↔ document-model)
28
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', [
29
34
  'graph_model_id',
@@ -80,6 +85,7 @@
80
85
  }
81
86
  },
82
87
  async down (db) {
88
+ await db.schema.dropTable('kubun_graph_search_models').ifExists().execute();
83
89
  await db.schema.dropTable('kubun_graph_catalogs').ifExists().execute();
84
90
  await db.schema.dropTable('kubun_graph_clusters').ifExists().execute();
85
91
  await db.schema.dropTable('kubun_graph_cluster_models').ifExists().execute();
@@ -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
@@ -55,6 +55,12 @@ export type GraphModelTable = {
55
55
  export type GraphModel = Selectable<GraphModelTable>;
56
56
  export type InsertGraphModel = Insertable<GraphModelTable>;
57
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>;
58
64
  export type GraphDocumentModelTable = {
59
65
  graph_model_id: string;
60
66
  document_model_id: string;
@@ -148,6 +154,7 @@ export type GraphTables = {
148
154
  kubun_graph_document_model_links: GraphDocumentModelTable;
149
155
  kubun_graph_models: GraphModelTable;
150
156
  kubun_graph_mutation_log: MutationLogTable;
157
+ kubun_graph_search_models: SearchModelTable;
151
158
  kubun_graph_user_model_access_defaults: UserModelAccessDefaultTable;
152
159
  [key: `k_${string}`]: DocumentTable;
153
160
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubun/store-graph",
3
- "version": "0.13.1",
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-adapter": "^0.13.0",
21
20
  "@kubun/id": "^0.13.0",
22
- "@kubun/protocol": "^0.13.1",
23
- "@kubun/db": "^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.13.0",
28
- "@kubun/db-postgres": "^0.13.0"
27
+ "@kubun/db-postgres": "^0.13.0",
28
+ "@kubun/db-better-sqlite": "^0.13.0"
29
29
  },
30
30
  "publishConfig": {
31
31
  "access": "public"