@memberjunction/generic-database-provider 5.32.0 → 5.34.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.
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * @module @memberjunction/generic-database-provider
17
17
  */
18
- import { DatabaseProviderBase, EntityFieldTSType, EntityPermissionType, InMemoryLocalStorageProvider, Metadata, RunView, QueryInfo, LocalCacheManager, LogError, LogStatus, LogStatusEx, StripStopWords, QueryCacheManager, } from '@memberjunction/core';
18
+ import { DatabaseProviderBase, EntityFieldTSType, EntityPermissionType, InMemoryLocalStorageProvider, Metadata, RunView, QueryInfo, LocalCacheManager, LogError, LogStatus, LogStatusEx, StripStopWords, QueryCacheManager, AfterKeyNotSupportedError, IsKeysetPaginationOrderableType, } from '@memberjunction/core';
19
19
  import { MJGlobal, SQLExpressionValidator, UUIDsEqual } from '@memberjunction/global';
20
20
  import { QueryPagingEngine } from './queryPagingEngine.js';
21
21
  // QueryParameterProcessor is now called internally by RenderPipeline
@@ -23,6 +23,7 @@ import { v4 as uuidv4 } from 'uuid';
23
23
  import { SqlLoggingSessionImpl } from './SqlLogger.js';
24
24
  // QueryCompositionEngine is now owned by RenderPipeline
25
25
  import { RenderPipeline } from './renderPipeline.js';
26
+ import { useJsonArgShape } from './crudSprocFieldRules.js';
26
27
  import { QueryEngine, ViewInfo, } from '@memberjunction/core-entities';
27
28
  import { AIEngine } from '@memberjunction/aiengine';
28
29
  import { QueueManager } from '@memberjunction/queue';
@@ -133,13 +134,18 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
133
134
  async CreateSqlLogger(filePath, options) {
134
135
  const sessionId = uuidv4();
135
136
  const mjCoreSchema = this.ConfigData.MJCoreSchemaName;
137
+ const dialect = this.getDialect();
136
138
  const session = new SqlLoggingSessionImpl(sessionId, filePath, {
137
139
  defaultSchemaName: mjCoreSchema,
138
140
  // Inject the platform's batch separator as the default so callers don't need to
139
141
  // hardcode 'GO'. Callers can still override by passing batchSeparator explicitly.
140
142
  batchSeparator: this.PlatformBatchSeparator || undefined,
141
143
  ...options
142
- });
144
+ },
145
+ // Pass the platform dialect through so SQL emission (Flyway placeholder escaping etc.)
146
+ // uses the right form for SQL Server vs. PostgreSQL. Falls back to the constructor's
147
+ // default (SQLServerDialect) when a subclass hasn't overridden getDialect().
148
+ dialect ?? undefined);
143
149
  // Initialize the session (create file, write header)
144
150
  await session.initialize();
145
151
  // Store in active sessions map
@@ -678,6 +684,38 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
678
684
  get PlatformBatchSeparator() {
679
685
  return this.getDialect()?.BatchSeparator() ?? '';
680
686
  }
687
+ /**
688
+ * Maximum number of parameters a CRUD stored procedure (`spCreate*`/`spUpdate*`/`spDelete*`)
689
+ * may declare on this database platform before CodeGen must emit a JSON-arg shape instead.
690
+ *
691
+ * - SQL Server: `Infinity` — SS supports up to 2,100 parameters per procedure, far beyond
692
+ * any realistic CRUD sproc.
693
+ * - PostgreSQL: `90` — PG's hard `FUNC_MAX_ARGS` ceiling is 100 (compiled into the server,
694
+ * not configurable on managed services like RDS/Aurora). 90 leaves headroom for column
695
+ * adds without flipping sproc shape unexpectedly.
696
+ *
697
+ * Used by the shared `useJsonArgShape` predicate (CodeGen + provider call-site) to decide
698
+ * whether a given sproc should emit as typed-args + `_Clear` companions (today's shape) or
699
+ * as a single-JSONB-arg sproc with key-presence tri-state semantics. See
700
+ * [plans/json-arg-crud-sprocs.md](../../../plans/json-arg-crud-sprocs.md) and GitHub
701
+ * issue #2552.
702
+ */
703
+ get ProcedureParamLimit() {
704
+ return Infinity;
705
+ }
706
+ /**
707
+ * Returns true when CRUD sproc generation and call-construction for the
708
+ * given entity + sproc verb should use a single JSON-arg shape (instead of
709
+ * typed args + `_Clear` companions).
710
+ *
711
+ * Convenience wrapper around the pure `useJsonArgShape` helper, applying
712
+ * this provider's `ProcedureParamLimit`. CodeGen and runtime call-sites
713
+ * can invoke this via the provider instance to keep sproc emit and sproc
714
+ * invocation in lockstep.
715
+ */
716
+ UseJsonArgShape(entity, sprocType) {
717
+ return useJsonArgShape(entity, sprocType, this.ProcedureParamLimit);
718
+ }
681
719
  /**
682
720
  * Builds a platform-specific TOP/LIMIT clause for non-paginated row limits.
683
721
  * SQL Server: `TOP N`; PostgreSQL returns empty (uses LIMIT via BuildPaginationSQL).
@@ -724,6 +762,138 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
724
762
  return null;
725
763
  return `SELECT COUNT(*) AS ${this.QuoteIdentifier('TotalRowCount')} FROM ${this.QuoteSchemaAndView(entityInfo.SchemaName, entityInfo.BaseView)}`;
726
764
  }
765
+ /**
766
+ * Validates that the entity and RunViewParams are compatible with keyset (AfterKey) pagination,
767
+ * then returns the SQL predicate (`<pk> > 'value'` or `<pk> < 'value'`) and the resolved
768
+ * order-by direction.
769
+ *
770
+ * See {@link AfterKeyNotSupportedError} for the validation rules and {@link RunViewParams.AfterKey}
771
+ * for the API contract.
772
+ *
773
+ * @throws AfterKeyNotSupportedError on validation failure
774
+ */
775
+ BuildKeysetSeekClause(entityInfo, params) {
776
+ const afterKey = params.AfterKey;
777
+ // 1. Single-column PK requirement
778
+ const pkFields = entityInfo.PrimaryKeys;
779
+ if (!pkFields || pkFields.length === 0) {
780
+ throw new AfterKeyNotSupportedError(entityInfo.Name, 'CompositePK', `AfterKey requires a primary key on entity "${entityInfo.Name}", but none is defined.`);
781
+ }
782
+ if (pkFields.length > 1) {
783
+ throw new AfterKeyNotSupportedError(entityInfo.Name, 'CompositePK', `AfterKey requires a single-column primary key. Entity "${entityInfo.Name}" has a composite PK (${pkFields.length} columns: ${pkFields.map(f => f.Name).join(', ')}). Use StartRow-based pagination for composite-PK entities, or restructure the workload.`);
784
+ }
785
+ const pkField = pkFields[0];
786
+ // 2. Orderable PK type
787
+ if (!IsKeysetPaginationOrderableType(pkField.Type)) {
788
+ throw new AfterKeyNotSupportedError(entityInfo.Name, 'UnsupportedPKType', `AfterKey is not supported for entity "${entityInfo.Name}" because its PK column "${pkField.Name}" has type "${pkField.Type}", which is not in the keyset-orderable allowlist.`);
789
+ }
790
+ // 3. StartRow conflict
791
+ if (params.StartRow !== undefined && params.StartRow > 0) {
792
+ throw new AfterKeyNotSupportedError(entityInfo.Name, 'StartRowConflict', `AfterKey cannot be combined with StartRow > 0. Use one or the other.`);
793
+ }
794
+ // 4. AfterKey shape — must contain exactly one key matching the PK column
795
+ const pairs = afterKey.KeyValuePairs ?? [];
796
+ if (pairs.length !== 1) {
797
+ throw new AfterKeyNotSupportedError(entityInfo.Name, 'AfterKeyShape', `AfterKey must contain exactly one key/value pair matching the entity's PK column "${pkField.Name}". Got ${pairs.length} pairs.`);
798
+ }
799
+ const pair = pairs[0];
800
+ if (pair.FieldName?.toLowerCase().trim() !== pkField.Name.toLowerCase().trim()) {
801
+ throw new AfterKeyNotSupportedError(entityInfo.Name, 'AfterKeyShape', `AfterKey key name "${pair.FieldName}" does not match the entity's PK column "${pkField.Name}".`);
802
+ }
803
+ if (pair.Value === null || pair.Value === undefined || pair.Value === '') {
804
+ throw new AfterKeyNotSupportedError(entityInfo.Name, 'AfterKeyShape', `AfterKey value for "${pkField.Name}" is null/empty. To request the first page, omit AfterKey entirely.`);
805
+ }
806
+ // 5. OrderBy compatibility — must be empty, or reference only the PK column
807
+ let direction = 'ASC';
808
+ const rawOrderBy = params.OrderBy?.trim() ?? '';
809
+ if (rawOrderBy.length > 0) {
810
+ // Accept: "ID", "ID ASC", "ID DESC", "[ID]", "[ID] DESC", '"ID"', etc.
811
+ // Strip quoting characters and split on whitespace.
812
+ const stripped = rawOrderBy.replace(/[\[\]"`]/g, '').trim();
813
+ const tokens = stripped.split(/\s+/);
814
+ const colName = tokens[0]?.trim();
815
+ const dirToken = tokens[1]?.toUpperCase().trim();
816
+ if (!colName || colName.toLowerCase() !== pkField.Name.toLowerCase()) {
817
+ throw new AfterKeyNotSupportedError(entityInfo.Name, 'IncompatibleOrderBy', `When AfterKey is set, OrderBy must reference only the PK column "${pkField.Name}". Got "${rawOrderBy}".`);
818
+ }
819
+ if (tokens.length > 2 || (dirToken && dirToken !== 'ASC' && dirToken !== 'DESC')) {
820
+ throw new AfterKeyNotSupportedError(entityInfo.Name, 'IncompatibleOrderBy', `When AfterKey is set, OrderBy may only specify a direction (ASC/DESC). Got "${rawOrderBy}".`);
821
+ }
822
+ if (dirToken === 'DESC')
823
+ direction = 'DESC';
824
+ }
825
+ // 6. Build the seek predicate. Comparison operator depends on direction.
826
+ const op = direction === 'ASC' ? '>' : '<';
827
+ const pkColumnName = this.QuoteIdentifier(pkField.Name);
828
+ const literalValue = this.formatKeysetSeekValue(pair.Value, pkField.Type);
829
+ const seekPredicate = `${pkColumnName} ${op} ${literalValue}`;
830
+ return { seekPredicate, direction, pkColumnName };
831
+ }
832
+ /**
833
+ * Formats a CompositeKey value as a SQL literal for use in a keyset seek predicate.
834
+ *
835
+ * This bypasses parameter binding because the entire WHERE clause is built as a string
836
+ * elsewhere in this provider (consistent with ExtraFilter / OrderBy handling). The seek
837
+ * value comes from server-side application code, not raw user input — but we still type-
838
+ * check and escape to defend against any caller passing a tainted value.
839
+ *
840
+ * Strategy:
841
+ * - UUID types: validate strict UUID format, wrap in single quotes
842
+ * - Numeric types: validate finite number, output bare
843
+ * - Boolean: output 0/1 (SQL Server) or TRUE/FALSE (caller can override if needed)
844
+ * - Date/time types: validate ISO-ish string, wrap in single quotes
845
+ * - String types: escape single quotes by doubling, wrap in single quotes
846
+ *
847
+ * @throws AfterKeyNotSupportedError if the value fails type-specific validation
848
+ */
849
+ formatKeysetSeekValue(value, sqlType) {
850
+ const t = (sqlType || '').replace(/\s*\([^)]*\)\s*$/, '').trim().toLowerCase();
851
+ const asStr = String(value);
852
+ // UUIDs — validate format strictly
853
+ if (t === 'uniqueidentifier' || t === 'uuid') {
854
+ const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
855
+ if (!uuidPattern.test(asStr)) {
856
+ throw new AfterKeyNotSupportedError('', 'AfterKeyShape', `AfterKey value "${asStr}" is not a valid UUID for ${sqlType} primary key.`);
857
+ }
858
+ return `'${asStr}'`;
859
+ }
860
+ // Numeric types
861
+ const numericTypes = new Set([
862
+ 'int', 'bigint', 'smallint', 'tinyint', 'integer', 'bigserial', 'serial',
863
+ 'decimal', 'numeric', 'money', 'smallmoney', 'float', 'real', 'double precision'
864
+ ]);
865
+ if (numericTypes.has(t)) {
866
+ const n = typeof value === 'number' ? value : Number(asStr);
867
+ if (!Number.isFinite(n)) {
868
+ throw new AfterKeyNotSupportedError('', 'AfterKeyShape', `AfterKey value "${asStr}" is not a valid numeric value for ${sqlType} primary key.`);
869
+ }
870
+ return String(n);
871
+ }
872
+ // Boolean / bit
873
+ if (t === 'bit' || t === 'boolean') {
874
+ const v = value === true || asStr === 'true' || asStr === '1';
875
+ return v ? '1' : '0';
876
+ }
877
+ // Date / time — accept anything ISO-ish; quote and escape single quotes
878
+ const dateTypes = new Set([
879
+ 'date', 'datetime', 'datetime2', 'datetimeoffset', 'smalldatetime', 'time',
880
+ 'timestamp', 'timestamp with time zone', 'timestamp without time zone'
881
+ ]);
882
+ if (dateTypes.has(t)) {
883
+ const escaped = asStr.replace(/'/g, "''");
884
+ // Reject anything containing semicolons or comment markers as a defense-in-depth check
885
+ if (/;|--|\/\*|\*\//.test(escaped)) {
886
+ throw new AfterKeyNotSupportedError('', 'AfterKeyShape', `AfterKey value for ${sqlType} primary key contains disallowed characters.`);
887
+ }
888
+ return `'${escaped}'`;
889
+ }
890
+ // String types (default)
891
+ const escaped = asStr.replace(/'/g, "''");
892
+ if (/;|--|\/\*|\*\//.test(escaped)) {
893
+ throw new AfterKeyNotSupportedError('', 'AfterKeyShape', `AfterKey value contains disallowed characters.`);
894
+ }
895
+ return `'${escaped}'`;
896
+ }
727
897
  /**
728
898
  * Transforms a user-provided SQL clause (ExtraFilter, OrderBy, etc.) for platform compatibility.
729
899
  * PostgreSQL overrides to quote mixed-case identifiers and convert bracket notation.
@@ -786,7 +956,21 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
786
956
  const overrideExcludeFilter = params.OverrideExcludeFilter ?? '';
787
957
  const saveViewResults = params.SaveViewResults ?? false;
788
958
  // ── TOP / pagination mode ──
789
- const usingPagination = !!(params.MaxRows && params.MaxRows > 0 && params.StartRow !== undefined && params.StartRow >= 0);
959
+ // Keyset (AfterKey) takes precedence: validate now so failures are early, predictable,
960
+ // and never silently degrade to OFFSET. Throws AfterKeyNotSupportedError if invalid.
961
+ const usingKeyset = !!params.AfterKey;
962
+ let keysetSeekPredicate = '';
963
+ let keysetDirection = 'ASC';
964
+ let keysetPkColumnName = '';
965
+ if (usingKeyset) {
966
+ const seek = this.BuildKeysetSeekClause(entityInfo, params);
967
+ keysetSeekPredicate = seek.seekPredicate;
968
+ keysetDirection = seek.direction;
969
+ keysetPkColumnName = seek.pkColumnName;
970
+ }
971
+ // usingPagination is the OFFSET-based path; keyset uses TOP/LIMIT semantics like a
972
+ // non-paginated query, so we never set usingPagination=true when AfterKey is present.
973
+ const usingPagination = !usingKeyset && !!(params.MaxRows && params.MaxRows > 0 && params.StartRow !== undefined && params.StartRow >= 0);
790
974
  let topSQL = '';
791
975
  let maxRowsForQuery = 0;
792
976
  if (params.IgnoreMaxRows === true) {
@@ -796,6 +980,13 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
796
980
  // pagination — no TOP, will add OFFSET/FETCH or LIMIT/OFFSET later
797
981
  maxRowsForQuery = params.MaxRows;
798
982
  }
983
+ else if (usingKeyset) {
984
+ // Keyset uses TOP/LIMIT like a non-paginated query — bounded by MaxRows.
985
+ // MaxRows is required for keyset to make sense (otherwise return whole table).
986
+ const keysetMaxRows = params.MaxRows && params.MaxRows > 0 ? params.MaxRows : (entityInfo.UserViewMaxRows && entityInfo.UserViewMaxRows > 0 ? entityInfo.UserViewMaxRows : 1000);
987
+ topSQL = this.BuildTopClause(keysetMaxRows);
988
+ maxRowsForQuery = keysetMaxRows;
989
+ }
799
990
  else if (params.MaxRows && params.MaxRows > 0) {
800
991
  topSQL = this.BuildTopClause(params.MaxRows);
801
992
  maxRowsForQuery = params.MaxRows;
@@ -859,14 +1050,32 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
859
1050
  bHasWhere = true;
860
1051
  }
861
1052
  }
1053
+ // 6. Keyset (AfterKey) seek predicate — only on the data query, NOT the count query.
1054
+ // The count is the total matching the user-visible filters; the seek predicate is
1055
+ // a pagination cursor, conceptually distinct from "how many records match overall".
1056
+ const seekPredicateForData = usingKeyset ? keysetSeekPredicate : '';
862
1057
  if (bHasWhere) {
863
1058
  viewSQL += ` WHERE ${whereSQL}`;
864
1059
  if (countSQL)
865
1060
  countSQL += ` WHERE ${whereSQL}`;
866
1061
  }
1062
+ if (seekPredicateForData) {
1063
+ viewSQL += bHasWhere ? ` AND (${seekPredicateForData})` : ` WHERE (${seekPredicateForData})`;
1064
+ }
867
1065
  // ── ORDER BY (transform user-provided clause for platform compatibility) ──
868
- const rawOrderBy = params.OrderBy ? params.OrderBy : (viewEntity ? viewEntity.OrderByClause ?? '' : '');
869
- const orderBy = rawOrderBy.length > 0 ? this.TransformExternalSQLClause(rawOrderBy, entityInfo) : '';
1066
+ // For keyset (AfterKey) mode, we force ORDER BY <pk> <direction> regardless of the
1067
+ // caller's OrderBy — BuildKeysetSeekClause already validated that any caller-provided
1068
+ // OrderBy referenced the PK and we use the resolved direction.
1069
+ let rawOrderBy;
1070
+ if (usingKeyset) {
1071
+ rawOrderBy = `${keysetPkColumnName} ${keysetDirection}`;
1072
+ }
1073
+ else {
1074
+ rawOrderBy = params.OrderBy ? params.OrderBy : (viewEntity ? viewEntity.OrderByClause ?? '' : '');
1075
+ }
1076
+ const orderBy = rawOrderBy.length > 0
1077
+ ? (usingKeyset ? rawOrderBy : this.TransformExternalSQLClause(rawOrderBy, entityInfo))
1078
+ : '';
870
1079
  // View run logging (SQL Server-specific, others return null)
871
1080
  let userViewRunID = '';
872
1081
  if (viewEntity?.ID && String(viewEntity.ID).length > 0 && saveViewResults && user) {
@@ -876,13 +1085,13 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
876
1085
  userViewRunID = logResult.runID;
877
1086
  }
878
1087
  else if (orderBy.length > 0) {
879
- if (!this.ValidateUserProvidedSQLClause(orderBy))
1088
+ if (!usingKeyset && !this.ValidateUserProvidedSQLClause(orderBy))
880
1089
  throw new Error(`Invalid Order By clause: ${orderBy}, contains one more for forbidden keywords`);
881
1090
  viewSQL += ` ORDER BY ${orderBy}`;
882
1091
  }
883
1092
  }
884
1093
  else if (orderBy.length > 0) {
885
- if (!this.ValidateUserProvidedSQLClause(orderBy))
1094
+ if (!usingKeyset && !this.ValidateUserProvidedSQLClause(orderBy))
886
1095
  throw new Error(`Invalid Order By clause: ${orderBy}, contains one more for forbidden keywords`);
887
1096
  viewSQL += ` ORDER BY ${orderBy}`;
888
1097
  }
@@ -894,7 +1103,9 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
894
1103
  viewSQL += ' ' + this.BuildPaginationSQL(params.MaxRows, params.StartRow);
895
1104
  }
896
1105
  else if (!topSQL && maxRowsForQuery > 0) {
897
- // Platform doesn't use TOP (e.g., PG uses LIMIT at end of query)
1106
+ // Platform doesn't use TOP (e.g., PG uses LIMIT at end of query).
1107
+ // This also covers the usingKeyset case on PG: topSQL is set to empty by PG's
1108
+ // BuildTopClause, so we fall here and emit LIMIT N at the end.
898
1109
  const limitSQL = this.BuildNonPaginatedLimitSQL(maxRowsForQuery);
899
1110
  if (limitSQL)
900
1111
  viewSQL += ' ' + limitSQL;
@@ -1082,7 +1293,14 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1082
1293
  }
1083
1294
  /**
1084
1295
  * Builds user search SQL for the given entity and search string.
1296
+ *
1085
1297
  * Supports full-text search (if enabled) and field-by-field LIKE searching.
1298
+ * For the LIKE path:
1299
+ * - honors EntityField.UserSearchPredicateAPI (Exact / BeginsWith / EndsWith / Contains)
1300
+ * so index-seekable predicates can be expressed,
1301
+ * - escapes LIKE metacharacters (%, _, [, ]) in user input with ESCAPE '\\',
1302
+ * - skips fields that are not sensible text-search targets (non-text types,
1303
+ * unbounded text columns when FTX is off).
1086
1304
  */
1087
1305
  createViewUserSearchSQL(entityInfo, userSearchString) {
1088
1306
  let sUserSearchSQL = '';
@@ -1106,23 +1324,78 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1106
1324
  sUserSearchSQL = `${pkName} IN (SELECT ${pkName} FROM ${this.QuoteSchemaAndView(entityInfo.SchemaName, entityInfo.FullTextSearchFunction ?? '')}('${u}'))`;
1107
1325
  }
1108
1326
  else {
1327
+ const escapedTerm = this.escapeLikeTerm(safeUserSearchString);
1109
1328
  for (const field of entityInfo.Fields) {
1110
- if (field.IncludeInUserSearchAPI) {
1111
- let sParam = '';
1112
- if (sUserSearchSQL.length > 0)
1113
- sUserSearchSQL += ' OR ';
1114
- if (field.UserSearchParamFormatAPI && field.UserSearchParamFormatAPI.length > 0)
1115
- sParam = field.UserSearchParamFormatAPI.replace('{0}', safeUserSearchString);
1116
- else
1117
- sParam = ` LIKE '%${safeUserSearchString}%'`;
1118
- sUserSearchSQL += `(${this.QuoteIdentifier(field.Name)} ${sParam})`;
1119
- }
1329
+ if (!field.IncludeInUserSearchAPI)
1330
+ continue;
1331
+ const sParam = this.buildPerFieldSearchPredicate(field, escapedTerm, safeUserSearchString);
1332
+ if (!sParam)
1333
+ continue;
1334
+ if (sUserSearchSQL.length > 0)
1335
+ sUserSearchSQL += ' OR ';
1336
+ sUserSearchSQL += `(${this.QuoteIdentifier(field.Name)} ${sParam})`;
1120
1337
  }
1121
1338
  if (sUserSearchSQL.length > 0)
1122
1339
  sUserSearchSQL = '(' + sUserSearchSQL + ')';
1123
1340
  }
1124
1341
  return sUserSearchSQL;
1125
1342
  }
1343
+ /**
1344
+ * Build the SQL fragment that compares one EntityField against a user search term.
1345
+ *
1346
+ * Resolution order:
1347
+ * 1. UserSearchParamFormatAPI (custom override) wins if set, with `{0}` replaced
1348
+ * by the single-quote-escaped raw term. Caller is responsible for escaping
1349
+ * LIKE metacharacters in their format string if needed.
1350
+ * 2. Otherwise, the field must be a text-searchable type. Non-text and unbounded-
1351
+ * text fields return '' (caller skips them).
1352
+ * 3. Otherwise, the predicate is chosen from UserSearchPredicateAPI:
1353
+ * Exact -> = N'term' (index-seekable)
1354
+ * BeginsWith -> LIKE N'term%' ESCAPE '\\' (index-seekable)
1355
+ * EndsWith -> LIKE N'%term' ESCAPE '\\'
1356
+ * Contains -> LIKE N'%term%' ESCAPE '\\' (default, non-seekable)
1357
+ */
1358
+ buildPerFieldSearchPredicate(field, escapedTerm, rawSafeTerm) {
1359
+ if (field.UserSearchParamFormatAPI && field.UserSearchParamFormatAPI.length > 0) {
1360
+ return field.UserSearchParamFormatAPI.replace('{0}', rawSafeTerm);
1361
+ }
1362
+ if (!this.isTextSearchableType(field))
1363
+ return '';
1364
+ const pred = (field.UserSearchPredicateAPI ?? 'Contains').trim();
1365
+ switch (pred) {
1366
+ case 'Exact':
1367
+ return ` = N'${rawSafeTerm}'`;
1368
+ case 'BeginsWith':
1369
+ return ` LIKE N'${escapedTerm}%' ESCAPE '\\'`;
1370
+ case 'EndsWith':
1371
+ return ` LIKE N'%${escapedTerm}' ESCAPE '\\'`;
1372
+ case 'Contains':
1373
+ default:
1374
+ return ` LIKE N'%${escapedTerm}%' ESCAPE '\\'`;
1375
+ }
1376
+ }
1377
+ /**
1378
+ * Escape characters that have special meaning in SQL Server LIKE patterns.
1379
+ * The backslash itself must be escaped first so its replacement isn't reprocessed.
1380
+ * Pair with `ESCAPE '\\'` on the LIKE clause.
1381
+ */
1382
+ escapeLikeTerm(safeTerm) {
1383
+ return safeTerm.replace(/[\\%_\[\]]/g, m => '\\' + m);
1384
+ }
1385
+ /**
1386
+ * True if the field's column type is appropriate for text-pattern search.
1387
+ * Non-text types are rejected because LIKE forces an implicit per-row CONVERT
1388
+ * to nvarchar. Unbounded (MAX/ntext/text) columns are rejected because the
1389
+ * LIKE path cannot seek them; FTX is the right tool for those.
1390
+ */
1391
+ isTextSearchableType(field) {
1392
+ const t = (field.Type ?? '').toLowerCase();
1393
+ if (t !== 'nvarchar' && t !== 'varchar' && t !== 'char' && t !== 'nchar')
1394
+ return false;
1395
+ if (field.Length === -1)
1396
+ return false;
1397
+ return true;
1398
+ }
1126
1399
  /**************************************************************************/
1127
1400
  // RenderViewWhereClause — View Template Rendering
1128
1401
  /**************************************************************************/
@@ -1177,6 +1450,24 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1177
1450
  /**************************************************************************/
1178
1451
  // Cache Check Utilities
1179
1452
  /**************************************************************************/
1453
+ /**
1454
+ * Parses a timestamp string sent by the client.
1455
+ *
1456
+ * Accepts both ISO 8601 strings (the canonical form) and all-digit strings
1457
+ * representing milliseconds since epoch. The numeric form has been observed
1458
+ * in the wild from clients whose cache layer round-tripped a Date through a
1459
+ * lossy serializer — `new Date('1778004618383')` returns `Invalid Date`,
1460
+ * but `new Date(Number('1778004618383'))` is a valid timestamp. Returning
1461
+ * `null` on unparseable input lets callers degrade to a stale-cache fallback
1462
+ * instead of throwing `RangeError: Invalid time value` on a downstream
1463
+ * `.toISOString()`.
1464
+ */
1465
+ parseClientTimestamp(raw) {
1466
+ if (!raw)
1467
+ return null;
1468
+ const d = /^\d+$/.test(raw) ? new Date(Number(raw)) : new Date(raw);
1469
+ return isNaN(d.getTime()) ? null : d;
1470
+ }
1180
1471
  /**
1181
1472
  * Compares client cache status with server status to determine if cache is current.
1182
1473
  * Checks both row count and maxUpdatedAt timestamp.
@@ -1184,11 +1475,23 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1184
1475
  isCacheCurrent(clientStatus, serverStatus) {
1185
1476
  if (clientStatus.rowCount !== serverStatus.rowCount)
1186
1477
  return false;
1187
- const clientDate = new Date(clientStatus.maxUpdatedAt);
1478
+ // Handle empty result sets: if both sides report no timestamp, matching row count is sufficient
1479
+ const clientEmpty = !clientStatus.maxUpdatedAt || clientStatus.maxUpdatedAt === '';
1480
+ const serverEmpty = !serverStatus.maxUpdatedAt || serverStatus.maxUpdatedAt === '';
1481
+ if (clientEmpty && serverEmpty)
1482
+ return true;
1483
+ const clientDate = this.parseClientTimestamp(clientStatus.maxUpdatedAt);
1484
+ if (!clientDate)
1485
+ return false;
1188
1486
  const serverDate = serverStatus.maxUpdatedAt ? new Date(serverStatus.maxUpdatedAt) : null;
1487
+ // Server has no timestamp (e.g., empty table) — current if row counts match (already checked above)
1189
1488
  if (!serverDate)
1190
1489
  return clientStatus.rowCount === 0;
1191
- return clientDate.toISOString() === serverDate.toISOString();
1490
+ if (isNaN(serverDate.getTime()))
1491
+ return false;
1492
+ // Compare as epoch milliseconds with 1-second tolerance to handle timestamp
1493
+ // precision differences between SQL Server and JavaScript Date.toISOString()
1494
+ return Math.abs(clientDate.getTime() - serverDate.getTime()) < 1000;
1192
1495
  }
1193
1496
  /**************************************************************************/
1194
1497
  // RunViewsWithCacheCheck — Shared Implementation
@@ -1212,7 +1515,10 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1212
1515
  const errorResults = [];
1213
1516
  for (let i = 0; i < params.length; i++) {
1214
1517
  const item = params[i];
1215
- if (!item.cacheStatus) {
1518
+ // Keyset queries bypass the cache entirely (per the AfterKey API contract):
1519
+ // each call uses a different seek key, so cached entries would never be
1520
+ // reusable. Route directly to standard execution path.
1521
+ if (!item.cacheStatus || item.params.AfterKey) {
1216
1522
  itemsWithoutCacheCheck.push({ index: i, item });
1217
1523
  continue;
1218
1524
  }
@@ -1466,12 +1772,17 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1466
1772
  const updatedRows = await this.getUpdatedRowsSince(params, entityInfo, clientMaxUpdatedAt, whereSQL, contextUser);
1467
1773
  const deletedRecordIDs = await this.getDeletedRecordIDsSince(entityInfo.ID, clientMaxUpdatedAt, contextUser);
1468
1774
  // Validation: detect hidden deletes not tracked in RecordChanges
1469
- const clientMaxUpdatedDate = new Date(clientMaxUpdatedAt);
1775
+ const clientMaxUpdatedDate = this.parseClientTimestamp(clientMaxUpdatedAt);
1776
+ if (!clientMaxUpdatedDate) {
1777
+ // Unparseable client timestamp — can't trust the differential math; fall back.
1778
+ return this.runFullQueryAndReturn(params, viewIndex, contextUser);
1779
+ }
1470
1780
  const newInserts = updatedRows.filter(row => {
1471
1781
  const createdAt = row['__mj_CreatedAt'];
1472
1782
  if (!createdAt)
1473
1783
  return false;
1474
- return new Date(String(createdAt)) > clientMaxUpdatedDate;
1784
+ const created = new Date(String(createdAt));
1785
+ return !isNaN(created.getTime()) && created > clientMaxUpdatedDate;
1475
1786
  }).length;
1476
1787
  const serverRowCount = serverStatus.rowCount ?? 0;
1477
1788
  const impliedDeletes = clientRowCount + newInserts - serverRowCount;