From 1696fe53d48f3bd79fbc6a121b825553b45e42dd Mon Sep 17 00:00:00 2001 From: bitmage Date: Sat, 3 Jan 2026 09:16:57 -0700 Subject: [PATCH] 7.4.0: add store.use() middleware --- coverage.json | 2 +- package-lock.json | 12 +- package.json | 6 +- site/js/version.ts | 2 +- src/@types/mergeable-store/docs.js | 17 + src/@types/mergeable-store/index.d.ts | 20 + src/@types/store/docs.js | 39 ++ src/@types/store/index.d.ts | 33 ++ src/common/middleware.ts | 263 +++++++++ src/mergeable-store/index.ts | 23 +- src/store/index.ts | 120 ++-- test/unit/core/store/mergeable-store.test.ts | 63 +++ test/unit/core/store/middleware.test.ts | 567 +++++++++++++++++++ 13 files changed, 1115 insertions(+), 52 deletions(-) create mode 100644 src/common/middleware.ts create mode 100644 test/unit/core/store/middleware.test.ts diff --git a/coverage.json b/coverage.json index 51a49c18861..0bbbf0fd5a2 100644 --- a/coverage.json +++ b/coverage.json @@ -1 +1 @@ -{"tests":7268,"assertions":32293,"lines":{"total":2408,"covered":2408,"skipped":0,"pct":100},"statements":{"total":2617,"covered":2617,"skipped":0,"pct":100},"functions":{"total":1046,"covered":1046,"skipped":0,"pct":100},"branches":{"total":906,"covered":906,"skipped":0,"pct":100},"branchesTrue":{"total":0,"covered":0,"skipped":0,"pct":100}} \ No newline at end of file +{"tests":4368,"assertions":24850,"lines":{"total":2496,"covered":2482,"skipped":0,"pct":99.43},"statements":{"total":2705,"covered":2688,"skipped":0,"pct":99.37},"functions":{"total":1053,"covered":1037,"skipped":0,"pct":98.48},"branches":{"total":950,"covered":943,"skipped":0,"pct":99.26},"branchesTrue":{"total":0,"covered":0,"skipped":0,"pct":100}} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 9626611763a..8c2013eec61 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "tinybase", - "version": "7.3.1", + "name": "@tlsft/tinybase", + "version": "7.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "tinybase", - "version": "7.3.1", + "name": "@tlsft/tinybase", + "version": "7.4.0", "license": "MIT", "devDependencies": { "@arethetypeswrong/cli": "^0.18.2", @@ -207,8 +207,8 @@ } }, "dist": { - "name": "tinybase", - "version": "7.3.0", + "name": "@tlsft/tinybase", + "version": "7.4.0", "dev": true, "license": "MIT", "peerDependencies": { diff --git a/package.json b/package.json index 3256a6ad0a9..9fd50983cef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "tinybase", - "version": "7.3.1", + "version": "7.4.0", + "private": true, "author": "jamesgpearce", "repository": "github:tinyplex/tinybase", "license": "MIT", @@ -128,7 +129,6 @@ "optional": true } }, - "private": true, "scripts": { "lintFiles": "gulp lintFiles", "lintDocs": "gulp lintDocs", @@ -262,4 +262,4 @@ "yup": "^1.7.1", "zod": "^4.2.1" } -} \ No newline at end of file +} diff --git a/site/js/version.ts b/site/js/version.ts index c8d49da7996..3088ab03823 100644 --- a/site/js/version.ts +++ b/site/js/version.ts @@ -1 +1 @@ -export const thisVersion = 'v7.3.1'; \ No newline at end of file +export const thisVersion = 'v7.4.0'; diff --git a/src/@types/mergeable-store/docs.js b/src/@types/mergeable-store/docs.js index b863fe67865..e35d10a54ff 100644 --- a/src/@types/mergeable-store/docs.js +++ b/src/@types/mergeable-store/docs.js @@ -921,6 +921,23 @@ * @since v5.0.0 */ /// MergeableStore.merge + /** + * The use method registers middleware to intercept mutations before they are + * applied. This is inherited from Store and works identically on + * MergeableStore. + * + * Middleware runs on both local mutations (setRow, setCell, etc.) and on + * incoming sync changes (applyMergeableChanges). It does NOT run on + * setMergeableContent (used for trusted persistence/hydration). + * + * See Store.use for full documentation and examples. + * @param tableId The table to intercept, or '*' for all tables. + * @param handler The handler function. + * @returns A reference to the MergeableStore for chaining. + * @category Middleware + * @since v7.4.0 + */ + /// MergeableStore.use } /** * The createMergeableStore function creates a MergeableStore, and is the main diff --git a/src/@types/mergeable-store/index.d.ts b/src/@types/mergeable-store/index.d.ts index b8b991d37bb..7c215b272a9 100644 --- a/src/@types/mergeable-store/index.d.ts +++ b/src/@types/mergeable-store/index.d.ts @@ -1,8 +1,10 @@ /// mergeable-store import type {GetNow, Hash, Hlc, Id} from '../common/index.d.ts'; import type { + AllTablesRowMiddleware, CellOrUndefined, Content, + RowMiddleware, Store, ValueOrUndefined, } from '../store/index.d.ts'; @@ -130,6 +132,24 @@ export interface MergeableStore extends Store { /// MergeableStore.merge merge(mergeableStore: MergeableStore): MergeableStore; + /// MergeableStore.use + /** + * Register middleware to intercept mutations before they are applied. + * + * Middleware runs on both local mutations (setRow, setCell, etc.) and + * incoming sync changes (applyMergeableChanges). + * + * Overloads: + * - `use(tableId, handler)` - Row-level middleware for a specific table. + * - `use('*', handler)` - Row-level middleware for all tables. + * + * Row-level handlers receive `(rowId, cells)` or `(tableId, rowId, cells)` + * and return the cells to accept (modified), or null to reject the row. + * @since v7.4.0 + */ + use(tableId: Id, handler: RowMiddleware): this; + use(tableId: '*', handler: AllTablesRowMiddleware): this; + /// Store.isMergeable isMergeable(): boolean; } diff --git a/src/@types/store/docs.js b/src/@types/store/docs.js index 2b63a354573..11b849ced4a 100644 --- a/src/@types/store/docs.js +++ b/src/@types/store/docs.js @@ -1272,6 +1272,31 @@ * @since v4.0.0 */ /// Changes +/** + * The RowMiddleware type represents a function that intercepts row mutations + * for a specific table before they are applied. + * + * The handler receives the rowId and cells object and can return the cells + * unchanged (accept), return modified cells (transform), or return null to + * reject the row mutation entirely. + * + * Register with `store.use(tableId, handler)`. + * @category Middleware + * @since v7.4.0 + */ +/// RowMiddleware +/** + * The AllTablesRowMiddleware type represents a function that intercepts row + * mutations for all tables before they are applied. + * + * This catch-all handler receives the tableId, rowId, and cells object. + * It runs after any table-specific handlers. + * + * Register with `store.use('*', handler)`. + * @category Middleware + * @since v7.4.0 + */ +/// AllTablesRowMiddleware /** * The TransactionLog type describes the changes that were made to a Store * during a transaction in detail. @@ -7267,6 +7292,20 @@ * @since v5.0.0 */ /// Store.isMergeable + /** + * The use method registers middleware to intercept mutations before they are + * applied to the Store. + * + * Middleware handlers can modify incoming data or reject mutations entirely. + * Use `use(tableId, handler)` for a specific table, or `use('*', handler)` + * to intercept mutations across all tables. + * @param tableId The Id of the Table to intercept, or '*' for all tables. + * @param handler The middleware handler function. + * @returns A reference to the Store. + * @category Middleware + * @since v7.4.0 + */ + /// Store.use } /** * The createStore function creates a Store, and is the main entry point into diff --git a/src/@types/store/index.d.ts b/src/@types/store/index.d.ts index 6b100f64051..af3378f756c 100644 --- a/src/@types/store/index.d.ts +++ b/src/@types/store/index.d.ts @@ -361,6 +361,24 @@ export type Changes = [ isChanges: 1, ]; +/// RowMiddleware +/** + * Row-level middleware handler for a specific table. + * @since v7.4.0 + */ +export type RowMiddleware = (rowId: Id | undefined, cells: Row) => Row | null; + +/// AllTablesRowMiddleware +/** + * Row-level middleware handler for all tables (catch-all). + * @since v7.4.0 + */ +export type AllTablesRowMiddleware = ( + tableId: Id, + rowId: Id | undefined, + cells: Row, +) => Row | null; + /// TransactionLog export type TransactionLog = [ cellsTouched: boolean, @@ -809,6 +827,21 @@ export interface Store { /// Store.isMergeable isMergeable(): boolean; + + /// Store.use + /** + * Register middleware to intercept mutations before they are applied. + * + * Overloads: + * - `use(tableId, handler)` - Row-level middleware for a specific table. + * - `use('*', handler)` - Row-level middleware for all tables. + * + * Row-level handlers receive `(rowId, cells)` or `(tableId, rowId, cells)` + * and return the cells to accept (modified), or null to reject the row. + * @since v7.4.0 + */ + use(tableId: Id, handler: RowMiddleware): this; + use(tableId: '*', handler: AllTablesRowMiddleware): this; // } diff --git a/src/common/middleware.ts b/src/common/middleware.ts new file mode 100644 index 00000000000..ced6325a7b6 --- /dev/null +++ b/src/common/middleware.ts @@ -0,0 +1,263 @@ +import type {Hlc, Id} from '../@types/common/index.d.ts'; +import type { + MergeableChanges, + MergeableContent, + RowStamp, +} from '../@types/mergeable-store/index.d.ts'; +import type {Changes, Row} from '../@types/store/index.d.ts'; +import {arrayReduce} from './array.ts'; +import {mapGet, mapNew, mapSet} from './map.ts'; +import {IdObj, objForEach, objIsEmpty} from './obj.ts'; + +// HLC generator function type (optional, only needed for MergeableStore) +type GetNextHlc = () => Hlc; + +// Row-level middleware for a specific table +export type RowMiddleware = (rowId: Id | undefined, cells: Row) => Row | null; + +// Row-level middleware for all tables (catch-all) +export type AllTablesRowMiddleware = ( + tableId: Id, + rowId: Id | undefined, + cells: Row, +) => Row | null; + +// Union type for any row middleware handler +export type MiddlewareHandler = RowMiddleware | AllTablesRowMiddleware; + +// Extract cells from a RowStamp - just the values, no stamps +const extractCells = (rowStamp: RowStamp): Row => { + const cells: Row = {}; + objForEach(rowStamp[0], ([value], cellId) => + value !== undefined ? (cells[cellId] = value) : 0, + ); + return cells; +}; + +// Rebuild rowStamp with transformed cells, preserving HLCs or generating new +const rebuildRowStamp = ( + rowStamp: RowStamp, + cells: Row, + getNextHlc: GetNextHlc, +): RowStamp => { + const newCellsStamp: IdObj = {}; + objForEach(cells, (value, cellId) => { + const orig = rowStamp[0][cellId]; + newCellsStamp[cellId] = orig + ? [value, orig[1], orig[2]] + : [value, getNextHlc()]; + }); + return [newCellsStamp, rowStamp[1], rowStamp[2]] as RowStamp; +}; + +// Run handlers through a chain, short-circuiting on null +const runHandlers = ( + initial: T, + handlers: ((arg: T) => T | null)[], +): T | null => + arrayReduce( + handlers, + (current, handler) => (current === null ? null : handler(current)), + initial as T | null, + ); + +// Run row-level handlers for a single row +const runRowHandlers = ( + tableMiddlewares: Map, + catchAllMiddlewares: AllTablesRowMiddleware[], + tableId: Id, + rowId: Id | undefined, + cells: Row, +): Row | null => { + const tableHandlers = mapGet(tableMiddlewares, tableId) || []; + const result = runHandlers( + cells, + tableHandlers.map((h) => (c: Row) => h(rowId, c)), + ); + return result === null + ? null + : runHandlers( + result, + catchAllMiddlewares.map((h) => (c: Row) => h(tableId, rowId, c)), + ); +}; + +// Apply row-level middleware to MergeableChanges (with stamps) +const applyToMergeableChanges = ( + tableMiddlewares: Map, + catchAllMiddlewares: AllTablesRowMiddleware[], + changes: MergeableChanges | MergeableContent, + getNextHlc: GetNextHlc, +): MergeableChanges | MergeableContent | null => { + const [tablesStamp, valuesStamp] = changes; + const tables = tablesStamp[0] as IdObj; + + if (!tables || objIsEmpty(tables)) return changes; + + const filteredTables: IdObj = {}; + + objForEach(tables, (tableStamp: unknown, tableId) => { + const [rows] = tableStamp as [IdObj>, unknown, unknown]; + const tableHandlers = mapGet(tableMiddlewares, tableId) || []; + const hasHandlers = + tableHandlers.length > 0 || catchAllMiddlewares.length > 0; + + if (!hasHandlers) { + filteredTables[tableId] = tableStamp; + return; + } + + const filteredRows: IdObj = {}; + + objForEach(rows, (rowStamp, rowId) => { + const cells = runRowHandlers( + tableMiddlewares, + catchAllMiddlewares, + tableId, + rowId, + extractCells(rowStamp), + ); + + if (cells !== null) { + filteredRows[rowId] = rebuildRowStamp(rowStamp, cells, getNextHlc); + } + }); + + if (!objIsEmpty(filteredRows)) { + filteredTables[tableId] = [ + filteredRows, + (tableStamp as [unknown, unknown, unknown])[1], + (tableStamp as [unknown, unknown, unknown])[2], + ]; + } + }); + + const newTablesStamp = [ + objIsEmpty(filteredTables) ? {} : filteredTables, + tablesStamp[1], + tablesStamp[2], + ]; + + return ( + changes.length === 3 + ? [newTablesStamp, valuesStamp, 1] + : [newTablesStamp, valuesStamp] + ) as MergeableChanges | MergeableContent; +}; + +// Apply row-level middleware to Changes (plain objects, no stamps) +const applyToChanges = ( + tableMiddlewares: Map, + catchAllMiddlewares: AllTablesRowMiddleware[], + changes: Changes, +): Changes | null => { + const [tables, values] = changes; + + if (!tables || objIsEmpty(tables)) return changes; + + const filteredTables: { + [tableId: Id]: + | {[rowId: Id]: {[cellId: Id]: unknown} | undefined} + | undefined; + } = {}; + let anyRowsKept = false; + + objForEach(tables, (table, tableId) => { + if (table === undefined) { + // Table deletion - pass through + filteredTables[tableId] = undefined; + anyRowsKept = true; + return; + } + + const tableHandlers = mapGet(tableMiddlewares, tableId) || []; + const hasHandlers = + tableHandlers.length > 0 || catchAllMiddlewares.length > 0; + + if (!hasHandlers) { + filteredTables[tableId] = table; + anyRowsKept = true; + return; + } + + const filteredRows: {[rowId: Id]: {[cellId: Id]: unknown} | undefined} = {}; + let tableHasRows = false; + + objForEach(table, (row, rowId) => { + if (row === undefined) { + // Row deletion - pass through + filteredRows[rowId] = undefined; + tableHasRows = true; + return; + } + + const cells = runRowHandlers( + tableMiddlewares, + catchAllMiddlewares, + tableId, + rowId, + row as Row, + ); + + if (cells !== null) { + filteredRows[rowId] = cells; + tableHasRows = true; + } + }); + + if (tableHasRows) { + filteredTables[tableId] = filteredRows; + anyRowsKept = true; + } + }); + + if (!anyRowsKept && objIsEmpty(values)) { + return null; + } + + return [filteredTables, values, 1] as Changes; +}; + +// Factory function that creates a middleware instance +export const createMiddleware = () => { + const tableMiddlewares: Map = mapNew(); + const catchAllMiddlewares: AllTablesRowMiddleware[] = []; + + return { + register: (tableId: Id, handler: MiddlewareHandler): void => { + if (tableId === '*') { + catchAllMiddlewares.push(handler as AllTablesRowMiddleware); + } else { + const handlers = mapGet(tableMiddlewares, tableId) || []; + handlers.push(handler as RowMiddleware); + mapSet(tableMiddlewares, tableId, handlers); + } + }, + + // Apply middleware to a single row (setRow, setCell, setPartialRow, addRow) + applyToRow: (tableId: Id, rowId: Id | undefined, cells: Row): Row | null => + runRowHandlers( + tableMiddlewares, + catchAllMiddlewares, + tableId, + rowId, + cells, + ), + + // Apply middleware to Changes (for applyChanges) + applyToChanges: (changes: Changes): Changes | null => + applyToChanges(tableMiddlewares, catchAllMiddlewares, changes), + + // Apply middleware to MergeableChanges (for applyMergeableChanges) + applyToMergeableChanges: ( + mergeableChanges: MergeableChanges | MergeableContent, + getNextHlc: GetNextHlc, + ): MergeableChanges | MergeableContent | null => + applyToMergeableChanges( + tableMiddlewares, + catchAllMiddlewares, + mergeableChanges, + getNextHlc, + ), + }; +}; diff --git a/src/mergeable-store/index.ts b/src/mergeable-store/index.ts index 14f70dd0332..67707dbf8d1 100644 --- a/src/mergeable-store/index.ts +++ b/src/mergeable-store/index.ts @@ -536,7 +536,10 @@ export const createMergeableStore = (( ? store.transaction(() => { store.delTables().delValues(); contentStampMap = newContentStampMap(); - store.applyChanges(mergeContentOrChanges(mergeableContent, 1)); + // Use _applyChanges to bypass middleware (trusted source) + (store as any)._applyChanges( + mergeContentOrChanges(mergeableContent, 1), + ); }) : 0, ); @@ -607,10 +610,19 @@ export const createMergeableStore = (( const applyMergeableChanges = ( mergeableChanges: MergeableChanges | MergeableContent, - ): MergeableStore => - disableListeningToRawStoreChanges(() => - store.applyChanges(mergeContentOrChanges(mergeableChanges)), + ): MergeableStore => { + const validatedChanges = (store as any).middleware.applyToMergeableChanges( + mergeableChanges, + getNextHlc, ); + if (validatedChanges === null) { + return mergeableStore as MergeableStore; + } + // Use _applyChanges to bypass base Store's middleware (already validated) + return disableListeningToRawStoreChanges(() => + (store as any)._applyChanges(mergeContentOrChanges(validatedChanges)), + ); + }; const merge = (mergeableStore2: MergeableStore) => { const mergeableChanges = getMergeableContent(); @@ -655,7 +667,8 @@ export const createMergeableStore = (( strStartsWith(name, DEL) || strStartsWith(name, 'apply') || strEndsWith(name, TRANSACTION) || - name == 'call' + LISTENER + name == 'call' + LISTENER || + name == 'use' ? (...args: any[]) => { method(...args); return mergeableStore; diff --git a/src/store/index.ts b/src/store/index.ts index 9016f19a81b..aa65585c392 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -44,11 +44,7 @@ import { arrayPush, arraySort, } from '../common/array.ts'; -import { - getCellOrValueType, - setOrDelCell, - setOrDelValue, -} from '../common/cell.ts'; +import {getCellOrValueType, setOrDelValue} from '../common/cell.ts'; import { collClear, collDel, @@ -87,6 +83,7 @@ import { mapToObj2, mapToObj3, } from '../common/map.ts'; +import {MiddlewareHandler, createMiddleware} from '../common/middleware.ts'; import { isObject, objDel, @@ -196,6 +193,7 @@ export const createStore: typeof createStoreDecl = (): Store => { const tableCellIds: IdMap> = mapNew(); const tablesMap: TablesMap = mapNew(); const valuesMap: ValuesMap = mapNew(); + const middleware = createMiddleware(); const hasTablesListeners: Pair = pairNewMap(); const tablesListeners: Pair = pairNewMap(); const tableIdsListeners: Pair = pairNewMap(); @@ -1160,10 +1158,13 @@ export const createStore: typeof createStoreDecl = (): Store => { const setRow = (tableId: Id, rowId: Id, row: Row): Store => fluentTransaction( - (tableId, rowId) => - validateRow(tableId, rowId, row) - ? setValidRow(tableId, getOrCreateTable(tableId), rowId, row) - : 0, + (tableId, rowId) => { + const validatedRow = middleware.applyToRow(tableId, rowId, row); + return validatedRow !== null && + validateRow(tableId, rowId, validatedRow) + ? setValidRow(tableId, getOrCreateTable(tableId), rowId, validatedRow) + : 0; + }, tableId, rowId, ); @@ -1171,14 +1172,12 @@ export const createStore: typeof createStoreDecl = (): Store => { const addRow = (tableId: Id, row: Row, reuseRowIds = true): Id | undefined => transaction(() => { let rowId: Id | undefined = undefined; - if (validateRow(tableId, rowId, row)) { - tableId = id(tableId); - setValidRow( - tableId, - getOrCreateTable(tableId), - (rowId = getNewRowId(tableId, reuseRowIds ? 1 : 0)), - row, - ); + tableId = id(tableId); + const validatedRow = middleware.applyToRow(tableId, undefined, row); + if (validatedRow !== null && validateRow(tableId, rowId, validatedRow)) { + const table = getOrCreateTable(tableId); + rowId = getNewRowId(tableId, reuseRowIds ? 1 : 0); + setValidRow(tableId, table, rowId, validatedRow); } return rowId; }); @@ -1186,9 +1185,13 @@ export const createStore: typeof createStoreDecl = (): Store => { const setPartialRow = (tableId: Id, rowId: Id, partialRow: Row): Store => fluentTransaction( (tableId, rowId) => { - if (validateRow(tableId, rowId, partialRow, 1)) { + const validatedRow = middleware.applyToRow(tableId, rowId, partialRow); + if ( + validatedRow !== null && + validateRow(tableId, rowId, validatedRow, 1) + ) { const table = getOrCreateTable(tableId); - objMap(partialRow, (cell, cellId) => + objMap(validatedRow, (cell, cellId) => setCellIntoDefaultRow(tableId, table, rowId, cellId, cell as Cell), ); } @@ -1204,28 +1207,60 @@ export const createStore: typeof createStoreDecl = (): Store => { cell: Cell | MapCell, ): Store => fluentTransaction( - (tableId, rowId, cellId) => - ifNotUndefined( - getValidatedCell( - tableId, - rowId, - cellId, - isFunction(cell) ? cell(getCell(tableId, rowId, cellId)) : cell, - ), - (validCell) => - setCellIntoDefaultRow( + (tableId, rowId, cellId) => { + const resolvedCell = isFunction(cell) + ? cell(getCell(tableId, rowId, cellId)) + : cell; + const row = {[cellId]: resolvedCell}; + const validatedRow = middleware.applyToRow(tableId, rowId, row); + if (validatedRow !== null) { + ifNotUndefined( + getValidatedCell( tableId, - getOrCreateTable(tableId), rowId, cellId, - validCell, + validatedRow[cellId] as Cell, ), - ), + (validCell) => + setCellIntoDefaultRow( + tableId, + getOrCreateTable(tableId), + rowId, + cellId, + validCell, + ), + ); + } + }, tableId, rowId, cellId, ); + // Internal setOrDelCell - bypasses middleware, for rollback and _applyChanges + const _setOrDelCell = ( + tableId: Id, + rowId: Id, + cellId: Id, + cell: CellOrUndefined, + ): void => { + if (isUndefined(cell)) { + delCell(tableId, rowId, cellId, true); + } else { + ifNotUndefined( + getValidatedCell(tableId, rowId, cellId, cell), + (validCell) => + setCellIntoDefaultRow( + tableId, + getOrCreateTable(tableId), + rowId, + cellId, + validCell, + ), + ); + } + }; + const setValues = (values: Values): Store => fluentTransaction(() => validateValues(values) ? setValidValues(values) : 0, @@ -1253,7 +1288,8 @@ export const createStore: typeof createStoreDecl = (): Store => { valueId, ); - const applyChanges = (changes: Changes): Store => + // Internal applyChanges without middleware (for MergeableStore sync path) + const _applyChanges = (changes: Changes): Store => fluentTransaction(() => { objMap(changes[0], (table, tableId) => isUndefined(table) @@ -1262,8 +1298,7 @@ export const createStore: typeof createStoreDecl = (): Store => { isUndefined(row) ? delRow(tableId, rowId) : objMap(row, (cell, cellId) => - setOrDelCell( - store, + _setOrDelCell( tableId, rowId, cellId, @@ -1277,6 +1312,12 @@ export const createStore: typeof createStoreDecl = (): Store => { ); }); + // Public applyChanges with middleware validation + const applyChanges = (changes: Changes): Store => { + const validatedChanges = middleware.applyToChanges(changes); + return validatedChanges === null ? store : _applyChanges(validatedChanges); + }; + const setTablesJson = (tablesJson: Json): Store => { tryCatch(() => setOrDelTables(jsonParse(tablesJson))); return store; @@ -1483,7 +1524,7 @@ export const createStore: typeof createStoreDecl = (): Store => { collForEach(changedCells, (table, tableId) => collForEach(table, (row, rowId) => collForEach(row, ([oldCell], cellId) => - setOrDelCell(store, tableId, rowId, cellId, oldCell), + _setOrDelCell(tableId, rowId, cellId, oldCell), ), ), ); @@ -1747,11 +1788,18 @@ export const createStore: typeof createStoreDecl = (): Store => { isMergeable: () => false, + use: (tableId: Id, handler: MiddlewareHandler): Store => { + middleware.register(tableId, handler); + return store; + }, + // only used internally by other modules createStore, addListener, callListeners, setInternalListeners, + _applyChanges, + middleware, }; // and now for some gentle meta-programming diff --git a/test/unit/core/store/mergeable-store.test.ts b/test/unit/core/store/mergeable-store.test.ts index 6307821764e..5d1b25debf3 100644 --- a/test/unit/core/store/mergeable-store.test.ts +++ b/test/unit/core/store/mergeable-store.test.ts @@ -1097,3 +1097,66 @@ describe('Merge', () => { }); }); }); + +describe('Middleware', () => { + let store1: MergeableStore; + let store2: MergeableStore; + + beforeEach(() => { + store1 = createMergeableStore('s1', getNow); + store2 = createMergeableStore('s2', getNow); + }); + + // Basic middleware tests - see middleware.test.ts for comprehensive tests + describe('use()', () => { + test('No middleware', () => { + store1.setCell('t1', 'r1', 'c1', 'v1'); + store2.applyMergeableChanges(store1.getMergeableContent()); + expect(store2.getCell('t1', 'r1', 'c1')).toEqual('v1'); + }); + + test('Passthrough', () => { + store2.use('*', (_tableId, _rowId, cells) => cells); + store1.setCell('t1', 'r1', 'c1', 'v1'); + store2.applyMergeableChanges(store1.getMergeableContent()); + expect(store2.getCell('t1', 'r1', 'c1')).toEqual('v1'); + }); + + test('Reject all', () => { + store2.use('*', () => null); + store1.setCell('t1', 'r1', 'c1', 'v1'); + store2.applyMergeableChanges(store1.getMergeableContent()); + expect(store2.getTables()).toEqual({}); + }); + + test('Fluent chaining', () => { + const result = store2.use('*', (_tableId, _rowId, cells) => cells); + expect(result).toBe(store2); + }); + + test('Works with merge()', () => { + store2.use('*', () => null); + store1.setCell('t1', 'r1', 'c1', 'v1'); + store2.merge(store1); + expect(store2.getTables()).toEqual({}); + expect(store1.getCell('t1', 'r1', 'c1')).toEqual('v1'); + }); + }); + + describe('setMergeableContent', () => { + // Middleware intentionally does NOT run on setMergeableContent. + // This method is for trusted sources (persistence, initial hydration). + // Use applyMergeableChanges for peer sync where validation is needed. + test('Bypasses middleware', () => { + let middlewareCalled = false; + store2.use('*', () => { + middlewareCalled = true; + return null; + }); + store1.setCell('t1', 'r1', 'c1', 'v1'); + store2.setMergeableContent(store1.getMergeableContent()); + expect(middlewareCalled).toBe(false); + expect(store2.getCell('t1', 'r1', 'c1')).toEqual('v1'); + }); + }); +}); diff --git a/test/unit/core/store/middleware.test.ts b/test/unit/core/store/middleware.test.ts new file mode 100644 index 00000000000..6dcc0b0ec50 --- /dev/null +++ b/test/unit/core/store/middleware.test.ts @@ -0,0 +1,567 @@ +import type {MergeableStore, Row, Store} from 'tinybase'; +import {createMergeableStore, createStore} from 'tinybase'; +import {beforeEach, describe, expect, test} from 'vitest'; +import {getTimeFunctions} from '../../common/mergeable.ts'; + +const [reset, getNow] = getTimeFunctions(); + +beforeEach(() => { + reset(); +}); + +describe('Base Store Middleware', () => { + let store: Store; + + beforeEach(() => { + store = createStore(); + }); + + describe('setRow', () => { + test('No middleware - row is set', () => { + store.setRow('t1', 'r1', {c1: 'v1'}); + expect(store.getRow('t1', 'r1')).toEqual({c1: 'v1'}); + }); + + test('Accept row by returning cells', () => { + store.use('t1', (_rowId, cells) => cells); + store.setRow('t1', 'r1', {c1: 'v1'}); + expect(store.getRow('t1', 'r1')).toEqual({c1: 'v1'}); + }); + + test('Reject row by returning null', () => { + store.use('t1', () => null); + store.setRow('t1', 'r1', {c1: 'v1'}); + expect(store.getTables()).toEqual({}); + }); + + test('Transform cells', () => { + store.use('t1', (_rowId, cells) => ({...cells, added: 'yes'})); + store.setRow('t1', 'r1', {c1: 'v1'}); + expect(store.getRow('t1', 'r1')).toEqual({c1: 'v1', added: 'yes'}); + }); + + test('Only affects specified table', () => { + store.use('t1', () => null); + store.setRow('t1', 'r1', {c1: 'v1'}); + store.setRow('t2', 'r1', {c1: 'v2'}); + expect(store.getTable('t1')).toEqual({}); + expect(store.getRow('t2', 'r1')).toEqual({c1: 'v2'}); + }); + + test('Fluent chaining', () => { + const result = store.use('t1', (_r, c) => c); + expect(result).toBe(store); + }); + }); + + describe('setCell', () => { + test('No middleware - cell is set', () => { + store.setCell('t1', 'r1', 'c1', 'v1'); + expect(store.getCell('t1', 'r1', 'c1')).toEqual('v1'); + }); + + test('Accept cell via row middleware', () => { + store.use('t1', (_rowId, cells) => cells); + store.setCell('t1', 'r1', 'c1', 'v1'); + expect(store.getCell('t1', 'r1', 'c1')).toEqual('v1'); + }); + + test('Reject cell by returning null', () => { + store.use('t1', () => null); + store.setCell('t1', 'r1', 'c1', 'v1'); + expect(store.getTables()).toEqual({}); + }); + + test('Transform cell value', () => { + store.use('t1', (_rowId, cells) => { + const result: Row = {}; + for (const [k, v] of Object.entries(cells)) { + result[k] = typeof v === 'string' ? v.toUpperCase() : v; + } + return result; + }); + store.setCell('t1', 'r1', 'c1', 'hello'); + expect(store.getCell('t1', 'r1', 'c1')).toEqual('HELLO'); + }); + }); + + describe('setPartialRow', () => { + test('No middleware - partial row is set', () => { + store.setRow('t1', 'r1', {c1: 'v1', c2: 'v2'}); + store.setPartialRow('t1', 'r1', {c2: 'updated'}); + expect(store.getRow('t1', 'r1')).toEqual({c1: 'v1', c2: 'updated'}); + }); + + test('Reject partial row by returning null', () => { + store.setRow('t1', 'r1', {c1: 'v1'}); + store.use('t1', () => null); + store.setPartialRow('t1', 'r1', {c2: 'v2'}); + // Original row unchanged + expect(store.getRow('t1', 'r1')).toEqual({c1: 'v1'}); + }); + + test('Transform partial row', () => { + store.setRow('t1', 'r1', {c1: 'v1'}); + store.use('t1', (_rowId, cells) => ({...cells, extra: 'added'})); + store.setPartialRow('t1', 'r1', {c2: 'v2'}); + expect(store.getRow('t1', 'r1')).toEqual({ + c1: 'v1', + c2: 'v2', + extra: 'added', + }); + }); + }); + + describe('addRow', () => { + test('No middleware - row is added', () => { + const rowId = store.addRow('t1', {c1: 'v1'}); + expect(rowId).toBeDefined(); + expect(store.getRow('t1', rowId!)).toEqual({c1: 'v1'}); + }); + + test('Reject row returns undefined', () => { + store.use('t1', () => null); + const rowId = store.addRow('t1', {c1: 'v1'}); + expect(rowId).toBeUndefined(); + expect(store.getTables()).toEqual({}); + }); + + test('Transform added row', () => { + store.use('t1', (_rowId, cells) => ({...cells, auto: 'yes'})); + const rowId = store.addRow('t1', {c1: 'v1'}); + expect(store.getRow('t1', rowId!)).toEqual({c1: 'v1', auto: 'yes'}); + }); + }); + + describe('applyChanges', () => { + test('No middleware - changes applied', () => { + store.applyChanges([{t1: {r1: {c1: 'v1'}}}, {}, 1]); + expect(store.getRow('t1', 'r1')).toEqual({c1: 'v1'}); + }); + + test('Reject rows via middleware', () => { + store.use('t1', () => null); + store.applyChanges([{t1: {r1: {c1: 'v1'}}}, {}, 1]); + expect(store.getTables()).toEqual({}); + }); + + test('Transform rows', () => { + store.use('t1', (_rowId, cells) => ({...cells, validated: true})); + store.applyChanges([{t1: {r1: {c1: 'v1'}}}, {}, 1]); + expect(store.getRow('t1', 'r1')).toEqual({c1: 'v1', validated: true}); + }); + + test('Row deletions pass through', () => { + store.setRow('t1', 'r1', {c1: 'v1'}); + store.use('t1', () => null); // reject middleware + // Deletions (undefined) should still pass through + store.applyChanges([{t1: {r1: undefined}}, {}, 1]); + expect(store.getTable('t1')).toEqual({}); + }); + + test('Table deletions pass through', () => { + store.setRow('t1', 'r1', {c1: 'v1'}); + store.use('t1', () => null); + store.applyChanges([{t1: undefined}, {}, 1]); + expect(store.getTables()).toEqual({}); + }); + }); + + describe('Catch-all middleware use("*", handler)', () => { + test('Runs for all tables', () => { + const seen: string[] = []; + store.use('*', (tableId, _rowId, cells) => { + seen.push(tableId); + return cells; + }); + store.setRow('t1', 'r1', {c1: 'v1'}); + store.setRow('t2', 'r1', {c1: 'v2'}); + expect(seen.sort()).toEqual(['t1', 't2']); + }); + + test('Receives correct tableId, rowId, cells', () => { + let received: [string, string | undefined, object] | undefined; + store.use('*', (tableId, rowId, cells) => { + received = [tableId, rowId, cells]; + return cells; + }); + store.setRow('myTable', 'myRow', {a: 1, b: 2}); + expect(received).toEqual(['myTable', 'myRow', {a: 1, b: 2}]); + }); + + test('Can reject rows from any table', () => { + store.use('*', () => null); + store.setRow('t1', 'r1', {c1: 'v1'}); + store.setRow('t2', 'r1', {c1: 'v2'}); + expect(store.getTables()).toEqual({}); + }); + + test('Runs after table-specific handlers', () => { + const order: string[] = []; + store + .use('t1', (_rowId, cells) => { + order.push('t1-specific'); + return cells; + }) + .use('*', (_tableId, _rowId, cells) => { + order.push('catch-all'); + return cells; + }); + store.setRow('t1', 'r1', {c1: 'v1'}); + expect(order).toEqual(['t1-specific', 'catch-all']); + }); + }); + + describe('Multiple handlers', () => { + test('Multiple handlers for same table run in order', () => { + const order: number[] = []; + store + .use('t1', (_rowId, cells) => { + order.push(1); + return cells; + }) + .use('t1', (_rowId, cells) => { + order.push(2); + return cells; + }); + store.setRow('t1', 'r1', {c1: 'v1'}); + expect(order).toEqual([1, 2]); + }); + + test('Short-circuit on null in chain', () => { + const order: number[] = []; + store + .use('t1', (_rowId, cells) => { + order.push(1); + return cells; + }) + .use('t1', () => { + order.push(2); + return null; + }) + .use('t1', (_rowId, cells) => { + order.push(3); + return cells; + }); + store.setRow('t1', 'r1', {c1: 'v1'}); + expect(order).toEqual([1, 2]); + expect(store.getTables()).toEqual({}); + }); + }); +}); + +describe('MergeableStore Middleware', () => { + let store1: MergeableStore; + let store2: MergeableStore; + + beforeEach(() => { + store1 = createMergeableStore('s1', getNow); + store2 = createMergeableStore('s2', getNow); + }); + + describe('Sync path (applyMergeableChanges)', () => { + test('No middleware - sync works', () => { + store1.setCell('t1', 'r1', 'c1', 'v1'); + store2.applyMergeableChanges(store1.getMergeableContent()); + expect(store2.getCell('t1', 'r1', 'c1')).toEqual('v1'); + }); + + test('Accept row by returning cells', () => { + store2.use('t1', (_rowId, cells) => cells); + store1.setCell('t1', 'r1', 'c1', 'v1'); + store2.applyMergeableChanges(store1.getMergeableContent()); + expect(store2.getCell('t1', 'r1', 'c1')).toEqual('v1'); + }); + + test('Reject row by returning null', () => { + store2.use('t1', () => null); + store1.setCell('t1', 'r1', 'c1', 'v1'); + store2.applyMergeableChanges(store1.getMergeableContent()); + expect(store2.getTables()).toEqual({}); + }); + + test('Only affects specified table', () => { + store2.use('t1', () => null); + store1.setCell('t1', 'r1', 'c1', 'v1'); + store1.setCell('t2', 'r1', 'c1', 'v2'); + store2.applyMergeableChanges(store1.getMergeableContent()); + expect(store2.getTable('t1')).toEqual({}); + expect(store2.getCell('t2', 'r1', 'c1')).toEqual('v2'); + }); + + test('Transform cells', () => { + store2.use('t1', (_rowId, cells) => ({...cells, added: 'yes'})); + store1.setCell('t1', 'r1', 'c1', 'v1'); + store2.applyMergeableChanges(store1.getMergeableContent()); + expect(store2.getRow('t1', 'r1')).toEqual({c1: 'v1', added: 'yes'}); + }); + + test('Added cell overwrites existing cell with older HLC', () => { + store2.setCell('t1', 'r1', 'existingCell', 'oldValue'); + store2.use('t1', (_rowId, cells) => ({ + ...cells, + existingCell: 'newValue', + })); + store1.setCell('t1', 'r1', 'c1', 'v1'); + store2.applyMergeableChanges(store1.getMergeableContent()); + expect(store2.getCell('t1', 'r1', 'existingCell')).toEqual('newValue'); + }); + + test('Receives correct rowId', () => { + let receivedRowId: string | undefined; + store2.use('t1', (rowId, cells) => { + receivedRowId = rowId; + return cells; + }); + store1.setCell('t1', 'myRow', 'c1', 'v1'); + store2.applyMergeableChanges(store1.getMergeableContent()); + expect(receivedRowId).toEqual('myRow'); + }); + + test('Multiple handlers run in order', () => { + const order: number[] = []; + store2 + .use('t1', (_rowId, cells) => { + order.push(1); + return cells; + }) + .use('t1', (_rowId, cells) => { + order.push(2); + return cells; + }); + store1.setCell('t1', 'r1', 'c1', 'v1'); + store2.applyMergeableChanges(store1.getMergeableContent()); + expect(order).toEqual([1, 2]); + }); + + test('Short-circuit on null', () => { + const order: number[] = []; + store2 + .use('t1', (_rowId, cells) => { + order.push(1); + return cells; + }) + .use('t1', () => { + order.push(2); + return null; + }) + .use('t1', (_rowId, cells) => { + order.push(3); + return cells; + }); + store1.setCell('t1', 'r1', 'c1', 'v1'); + store2.applyMergeableChanges(store1.getMergeableContent()); + expect(order).toEqual([1, 2]); + expect(store2.getTables()).toEqual({}); + }); + + test('Fluent chaining', () => { + const result = store2.use('t1', (_r, c) => c); + expect(result).toBe(store2); + }); + + test('Mixed valid/invalid rows', () => { + store2.use('t1', (_rowId, cells) => { + return cells.reject ? null : cells; + }); + store1.setRow('t1', 'valid', {c1: 'v1'}); + store1.setRow('t1', 'invalid', {c1: 'v2', reject: true}); + store2.applyMergeableChanges(store1.getMergeableContent()); + expect(store2.getRowIds('t1')).toEqual(['valid']); + }); + }); + + describe('Local mutations (setRow, setCell)', () => { + test('Middleware runs on local setRow', () => { + store2.use('t1', () => null); + store2.setRow('t1', 'r1', {c1: 'v1'}); + expect(store2.getTables()).toEqual({}); + }); + + test('Middleware runs on local setCell', () => { + store2.use('t1', () => null); + store2.setCell('t1', 'r1', 'c1', 'v1'); + expect(store2.getTables()).toEqual({}); + }); + + test('Same middleware runs on both local and sync', () => { + const calls: string[] = []; + store2.use('t1', (_rowId, cells) => { + calls.push('middleware'); + return cells; + }); + + // Local mutation + store2.setRow('t1', 'local', {c1: 'local'}); + expect(calls).toEqual(['middleware']); + + // Sync from remote + store1.setRow('t1', 'remote', {c1: 'remote'}); + store2.applyMergeableChanges(store1.getMergeableContent()); + expect(calls).toEqual(['middleware', 'middleware']); + }); + }); + + describe('Catch-all middleware use("*", handler)', () => { + test('Runs for all tables on sync', () => { + const seen: string[] = []; + store2.use('*', (tableId, _rowId, cells) => { + seen.push(tableId); + return cells; + }); + store1.setCell('t1', 'r1', 'c1', 'v1'); + store1.setCell('t2', 'r1', 'c1', 'v2'); + store2.applyMergeableChanges(store1.getMergeableContent()); + expect(seen.sort()).toEqual(['t1', 't2']); + }); + + test('Receives correct tableId, rowId, cells', () => { + let received: [string, string | undefined, object] | undefined; + store2.use('*', (tableId, rowId, cells) => { + received = [tableId, rowId, cells]; + return cells; + }); + store1.setRow('myTable', 'myRow', {a: 1, b: 2}); + store2.applyMergeableChanges(store1.getMergeableContent()); + expect(received).toEqual(['myTable', 'myRow', {a: 1, b: 2}]); + }); + + test('Runs after table-specific handlers', () => { + const order: string[] = []; + store2 + .use('t1', (_rowId, cells) => { + order.push('t1-specific'); + return cells; + }) + .use('*', (_tableId, _rowId, cells) => { + order.push('catch-all'); + return cells; + }); + store1.setCell('t1', 'r1', 'c1', 'v1'); + store2.applyMergeableChanges(store1.getMergeableContent()); + expect(order).toEqual(['t1-specific', 'catch-all']); + }); + + test('Can reject rows', () => { + store2.use('*', () => null); + store1.setCell('t1', 'r1', 'c1', 'v1'); + store2.applyMergeableChanges(store1.getMergeableContent()); + expect(store2.getTables()).toEqual({}); + }); + + test('Can transform rows', () => { + store2.use('*', (tableId, _rowId, cells) => ({ + ...cells, + fromTable: tableId, + })); + store1.setCell('t1', 'r1', 'c1', 'v1'); + store2.applyMergeableChanges(store1.getMergeableContent()); + expect(store2.getRow('t1', 'r1')).toEqual({c1: 'v1', fromTable: 't1'}); + }); + }); + + describe('setMergeableContent', () => { + // Middleware intentionally does NOT run on setMergeableContent. + // This method is for trusted sources (persistence, initial hydration). + // Use applyMergeableChanges for peer sync where validation is needed. + test('Bypasses middleware', () => { + let rowCalled = false; + let catchAllCalled = false; + store2 + .use('t1', () => { + rowCalled = true; + return null; + }) + .use('*', () => { + catchAllCalled = true; + return null; + }); + store1.setCell('t1', 'r1', 'c1', 'v1'); + store2.setMergeableContent(store1.getMergeableContent()); + expect(rowCalled).toBe(false); + expect(catchAllCalled).toBe(false); + expect(store2.getCell('t1', 'r1', 'c1')).toEqual('v1'); + }); + }); + + describe('merge()', () => { + test('Middleware runs on merge', () => { + store2.use('t1', () => null); + store1.setCell('t1', 'r1', 'c1', 'v1'); + store2.merge(store1); + expect(store2.getTables()).toEqual({}); + // store1 should be unchanged (received store2's empty content) + expect(store1.getCell('t1', 'r1', 'c1')).toEqual('v1'); + }); + }); + + describe('Middleware side-effects with setRow', () => { + test('setRow inside middleware persists locally', () => { + store2.use('jobs', (_rowId, cells) => { + store2.setRow('audit', 'a1', { + action: 'job-created', + jobId: _rowId ?? 'unknown', + }); + return cells; + }); + + store1.setRow('jobs', 'j1', {name: 'Test Job'}); + store2.applyMergeableChanges(store1.getMergeableContent()); + + expect(store2.getRow('jobs', 'j1')).toEqual({name: 'Test Job'}); + expect(store2.getRow('audit', 'a1')).toEqual({ + action: 'job-created', + jobId: 'j1', + }); + }); + + test('setRow side-effects sync back to originating store', () => { + store2.use('jobs', (rowId, cells) => { + store2.setRow('audit', `audit-${rowId ?? 'unknown'}`, { + action: 'job-received', + jobId: rowId ?? 'unknown', + }); + return cells; + }); + + store1.setRow('jobs', 'j1', {name: 'Test Job'}); + store2.applyMergeableChanges(store1.getMergeableContent()); + + expect(store2.getRow('jobs', 'j1')).toEqual({name: 'Test Job'}); + expect(store2.getRow('audit', 'audit-j1')).toEqual({ + action: 'job-received', + jobId: 'j1', + }); + + store1.applyMergeableChanges(store2.getMergeableContent()); + + expect(store1.getRow('audit', 'audit-j1')).toEqual({ + action: 'job-received', + jobId: 'j1', + }); + }); + + test('middleware can read existing store state', () => { + store2.setRow('config', 'settings', {auditEnabled: true}); + + store2.use('jobs', (rowId, cells) => { + const settings = store2.getRow('config', 'settings'); + if (settings.auditEnabled) { + store2.setRow('audit', `audit-${rowId ?? 'unknown'}`, { + action: 'job-created', + jobId: rowId ?? 'unknown', + }); + } + return cells; + }); + + store1.setRow('jobs', 'j1', {name: 'Test Job'}); + store2.applyMergeableChanges(store1.getMergeableContent()); + + expect(store2.getRow('audit', 'audit-j1')).toEqual({ + action: 'job-created', + jobId: 'j1', + }); + }); + }); +});