@spooky-sync/query-builder 0.0.1-canary.21 → 0.0.1-canary.211

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.
@@ -7,6 +7,12 @@ import type {
7
7
  RelatedQuery,
8
8
  SchemaAwareQueryModifier,
9
9
  SchemaAwareQueryModifierBuilder,
10
+ WhereInput,
11
+ QueryPlan,
12
+ RelationPlan,
13
+ WhereNode,
14
+ WhereComparison,
15
+ ComparisonOp,
10
16
  } from './types';
11
17
  import type {
12
18
  TableNames,
@@ -19,6 +25,59 @@ import type {
19
25
  ColumnSchema,
20
26
  } from './table-schema';
21
27
 
28
+ /**
29
+ * Reject a query clause that references an `-- @opaque` column.
30
+ *
31
+ * An `@opaque` value is synced down to the client but never held server-side, so
32
+ * the SSP has nothing to evaluate a predicate against. Left unchecked, the
33
+ * failure is silent and asymmetric: the local cache DOES hold the value, so the
34
+ * clause filters correctly on screen while the server-side membership set it is
35
+ * compared against was computed without it. The result reads as rows randomly
36
+ * appearing and vanishing rather than as an error, so fail loudly at the call
37
+ * site instead.
38
+ *
39
+ * `clause` names the offending API ('where', 'orderBy', …) for the message.
40
+ */
41
+ function assertNotOpaque(
42
+ schema: SchemaStructure,
43
+ tableName: string,
44
+ clause: string,
45
+ fields: readonly string[]
46
+ ): void {
47
+ const table = schema.tables.find((t) => t.name === tableName);
48
+ if (!table) return;
49
+ for (const field of fields) {
50
+ // A nested path (`meta.secret`) is checked against its root, since the column
51
+ // flag lives on `meta`. Comparison operators use `{ field: { _op, _val } }`
52
+ // rather than a name suffix, so the key is always the bare column name.
53
+ const column: ColumnSchema | undefined =
54
+ table.columns[field] ?? table.columns[field.split('.')[0]];
55
+ if (column?.opaque) {
56
+ throw new Error(
57
+ `Cannot use '${field}' in ${clause}(): it is marked '-- @opaque' on ` +
58
+ `${tableName}, so the sync engine never stores its value and cannot ` +
59
+ `evaluate it. Read the field from query results instead, or remove ` +
60
+ `'-- @opaque' from the schema if you need to query on it.`
61
+ );
62
+ }
63
+ }
64
+ }
65
+
66
+ /** Every column name a `where` clause touches, including `_or` branches. */
67
+ function whereFieldNames(conditions: Record<string, unknown>): string[] {
68
+ const out: string[] = [];
69
+ for (const [key, value] of Object.entries(conditions)) {
70
+ if (key === '_or') {
71
+ for (const branch of (value ?? []) as Record<string, unknown>[]) {
72
+ out.push(...whereFieldNames(branch ?? {}));
73
+ }
74
+ continue;
75
+ }
76
+ out.push(key);
77
+ }
78
+ return out;
79
+ }
80
+
22
81
  /**
23
82
  * Parse a string ID to RecordId
24
83
  * - If it's in the format "table:id", use it as-is
@@ -172,7 +231,7 @@ export class InnerQuery<
172
231
  /**
173
232
  * Helper type to get the model type for a related table
174
233
  */
175
- type GetRelatedModel<S extends SchemaStructure, RelatedTableName extends string> =
234
+ type _GetRelatedModel<S extends SchemaStructure, RelatedTableName extends string> =
176
235
  RelatedTableName extends TableNames<S> ? TableModel<GetTable<S, RelatedTableName>> : never;
177
236
 
178
237
  /**
@@ -234,6 +293,7 @@ export class FinalQuery<
234
293
  S extends SchemaStructure,
235
294
  TableName extends TableNames<S>,
236
295
  T extends { columns: Record<string, ColumnSchema> },
296
+ // oxlint-disable-next-line no-unused-vars -- RelatedFields is used externally for type inference
237
297
  RelatedFields extends RelatedFieldsMap,
238
298
  IsOne extends boolean,
239
299
  R = void,
@@ -299,7 +359,13 @@ class SchemaAwareQueryModifierBuilderImpl<
299
359
  private readonly schema: S
300
360
  ) {}
301
361
 
302
- where(conditions: Partial<TableModel<GetTable<S, TableName>>>): this {
362
+ where(conditions: WhereInput<TableModel<GetTable<S, TableName>>>): this {
363
+ assertNotOpaque(
364
+ this.schema,
365
+ this.tableName,
366
+ 'where',
367
+ whereFieldNames(conditions as Record<string, unknown>)
368
+ );
303
369
  this.options.where = { ...this.options.where, ...conditions };
304
370
  return this;
305
371
  }
@@ -326,6 +392,7 @@ class SchemaAwareQueryModifierBuilderImpl<
326
392
  field: keyof TableModel<GetTable<S, TableName>> & string,
327
393
  direction: 'asc' | 'desc' = 'asc'
328
394
  ): this {
395
+ assertNotOpaque(this.schema, this.tableName, 'orderBy', [field]);
329
396
  this.options.orderBy = {
330
397
  ...this.options.orderBy,
331
398
  [field]: direction,
@@ -365,9 +432,18 @@ class SchemaAwareQueryModifierBuilderImpl<
365
432
  );
366
433
 
367
434
  if (!relationship) {
368
- throw new Error(
369
- `Relationship '${String(relatedField)}' not found for table '${this.tableName}'`
370
- );
435
+ // No such relationship in the client schema — e.g. a table owned by a
436
+ // devOnly backend (the outbox `job`) that a free/Cloudflare deployment
437
+ // never provisions, so codegen omits it + its relationships. Skip the
438
+ // projection instead of throwing, which would take the whole query
439
+ // (and its other `.related()` siblings — author, comments) down.
440
+ // Mirrors the server's "unpermitted subquery → empty" degradation.
441
+ if (typeof console !== 'undefined') {
442
+ console.warn(
443
+ `[sp00ky] .related('${String(relatedField)}') skipped — no such relationship on '${this.tableName}' in the client schema`
444
+ );
445
+ }
446
+ return this as any;
371
447
  }
372
448
 
373
449
  const relatedTable = relationship.to;
@@ -412,8 +488,14 @@ export class QueryBuilder<
412
488
  * Add additional where conditions
413
489
  */
414
490
  where(
415
- conditions: Partial<TableModel<GetTable<S, TableName>>>
491
+ conditions: WhereInput<TableModel<GetTable<S, TableName>>>
416
492
  ): QueryBuilder<S, TableName, R, RelatedFields, IsOne> {
493
+ assertNotOpaque(
494
+ this.schema,
495
+ this.tableName,
496
+ 'where',
497
+ whereFieldNames(conditions as Record<string, unknown>)
498
+ );
417
499
  this.options.where = { ...this.options.where, ...conditions };
418
500
  return this;
419
501
  }
@@ -438,6 +520,7 @@ export class QueryBuilder<
438
520
  field: TableFieldNames<GetTable<S, TableName>>,
439
521
  direction: 'asc' | 'desc' = 'asc'
440
522
  ): QueryBuilder<S, TableName, R, RelatedFields, IsOne> {
523
+ assertNotOpaque(this.schema, this.tableName, 'orderBy', [field as string]);
441
524
  this.options.orderBy = {
442
525
  ...this.options.orderBy,
443
526
  [field]: direction,
@@ -515,7 +598,15 @@ export class QueryBuilder<
515
598
  );
516
599
 
517
600
  if (!relationship) {
518
- throw new Error(`Relationship '${String(field)}' not found for table '${this.tableName}'`);
601
+ // See the note on the other `.related()` overload: skip an unknown
602
+ // relationship (warn) rather than throwing, so a table absent from the
603
+ // client schema (e.g. the free-plan `job` outbox) can't crash the query.
604
+ if (typeof console !== 'undefined') {
605
+ console.warn(
606
+ `[sp00ky] .related('${String(field)}') skipped — no such relationship on '${this.tableName}' in the client schema`
607
+ );
608
+ }
609
+ return this as any;
519
610
  }
520
611
 
521
612
  // Determine cardinality and modifier based on arguments
@@ -600,6 +691,28 @@ export class QueryBuilder<
600
691
  }
601
692
  }
602
693
 
694
+ /**
695
+ * The surql param name an `_or` branch condition binds under: the branch FIELD,
696
+ * a `__or` marker and the branch's position. The position alone makes it unique
697
+ * within the query; the field prefix is what lets a consumer type the value by
698
+ * looking the column up (see {@link baseFieldOfParam}). Non-identifier
699
+ * characters (a nested path like `database.owner`) are folded to `_` so the
700
+ * result is a legal param name.
701
+ */
702
+ export function orParamName(field: string, index: number): string {
703
+ return `${field.replace(/[^A-Za-z0-9_]/g, '_')}__or${index}`;
704
+ }
705
+
706
+ /**
707
+ * Inverse of {@link orParamName}: the field a param name was built from, or the
708
+ * name itself when it is a plain top-level param (`field = $field`). Lets a
709
+ * consumer resolve `white__or0` back to the `white` column.
710
+ */
711
+ export function baseFieldOfParam(name: string): string {
712
+ const m = /^(.+)__or\d+$/.exec(name);
713
+ return m ? m[1] : name;
714
+ }
715
+
603
716
  export function cyrb53(str: string, seed: number = 0): number {
604
717
  let h1 = 0xdeadbeef ^ seed,
605
718
  h2 = 0x41c6ce57 ^ seed;
@@ -641,6 +754,7 @@ export function extractSubqueryQueryInfos<S extends SchemaStructure>(
641
754
  if (relationship) {
642
755
  // Determine foreign key field
643
756
  // rel.alias is guaranteed to be defined if relationship is found (matched r.field)
757
+ // oxlint-disable-next-line no-non-null-assertion -- alias is guaranteed defined when relationship is found
644
758
  let foreignKeyField = rel.alias!;
645
759
 
646
760
  if (relationship.cardinality === 'many') {
@@ -751,32 +865,57 @@ export function buildQueryFromOptions<TModel extends GenericModel, IsOne extends
751
865
  const vars: Record<string, unknown> = {};
752
866
  if (parsedWhere && Object.keys(parsedWhere).length > 0) {
753
867
  const conditions: string[] = [];
754
- for (const [key, value] of Object.entries(parsedWhere)) {
755
- const varName = key;
756
868
 
757
- // Handle operator objects { _op, _val }
869
+ // Build a single condition for `field`, binding its value under `varName`.
870
+ // Supports operator objects `{ _op, _val, _swap }` (e.g. `{ _op: '<=', _val:
871
+ // 5 }`); a `$`-prefixed string `_val` references an existing param verbatim.
872
+ // Plain values mean equality (`field = $varName`).
873
+ const buildCondition = (field: string, value: unknown, varName: string): string => {
758
874
  if (value && typeof value === 'object' && '_op' in value && '_val' in value) {
759
875
  const { _op, _val, _swap } = value as { _op: string; _val: unknown; _swap?: boolean };
760
-
761
- let rightSide = '';
876
+ let rightSide: string;
762
877
  if (typeof _val === 'string' && _val.startsWith('$')) {
763
878
  rightSide = _val;
764
879
  } else {
765
880
  vars[varName] = _val;
766
881
  rightSide = `$${varName}`;
767
882
  }
883
+ return _swap ? `${rightSide} ${_op} ${field}` : `${field} ${_op} ${rightSide}`;
884
+ }
885
+ vars[varName] = value;
886
+ return `${field} = $${varName}`;
887
+ };
768
888
 
769
- if (_swap) {
770
- conditions.push(`${rightSide} ${_op} ${key}`);
771
- } else {
772
- conditions.push(`${key} ${_op} ${rightSide}`);
889
+ for (const [key, value] of Object.entries(parsedWhere)) {
890
+ // OR-group: `{ _or: [ {field: val}, {field: {_op,_val}}, ... ] }` compiles
891
+ // to one parenthesised `(c1 OR c2 ...)` conjunct. Each branch condition gets
892
+ // a unique, position-indexed param name (`white__or0`, `black__or1`, ) so
893
+ // it never collides with a top-level condition on the same field (e.g. a
894
+ // `white = $white` filter alongside an opponent `_or` on white/black) -
895
+ // keeping the surql + vars, and thus the query hash, stable and
896
+ // deterministic. The FIELD is part of the name on purpose: the consumer
897
+ // types a param by looking its name up in the table's columns
898
+ // (`parseQueryParams` in @spooky-sync/core), and a name it cannot resolve
899
+ // used to be dropped from the registration, which left `$or0` unbound and
900
+ // every `_or` query matching nothing.
901
+ if (key === '_or' && Array.isArray(value)) {
902
+ const orParts: string[] = [];
903
+ let i = 0;
904
+ for (const branch of value) {
905
+ if (branch && typeof branch === 'object') {
906
+ for (const [bField, bVal] of Object.entries(branch as Record<string, unknown>)) {
907
+ orParts.push(buildCondition(bField, bVal, orParamName(bField, i++)));
908
+ }
909
+ }
773
910
  }
774
- } else {
775
- vars[varName] = value;
776
- conditions.push(`${key} = $${varName}`);
911
+ if (orParts.length > 0) conditions.push(`(${orParts.join(' OR ')})`);
912
+ continue;
777
913
  }
914
+
915
+ conditions.push(buildCondition(key, value, key));
778
916
  }
779
- query += ` WHERE ${conditions.join(' AND ')}`;
917
+
918
+ if (conditions.length > 0) query += ` WHERE ${conditions.join(' AND ')}`;
780
919
  }
781
920
 
782
921
  // Add PATCH for UPDATE
@@ -815,9 +954,193 @@ export function buildQueryFromOptions<TModel extends GenericModel, IsOne extends
815
954
  0
816
955
  ),
817
956
  vars: Object.keys(vars).length > 0 ? vars : undefined,
957
+ // Engine-neutral plan mirrors the SELECT above for non-SurrealQL backends.
958
+ // Only SELECT carries a plan; the isOne→limit=1 mutation above is already
959
+ // reflected in `options.limit`, so the plan sees it too.
960
+ plan: method === 'SELECT' ? buildQueryPlan(tableName, options, schema) : undefined,
818
961
  };
819
962
  }
820
963
 
964
+ /**
965
+ * Build the engine-neutral {@link QueryPlan} for a SELECT. Mirrors the string
966
+ * assembly in {@link buildQueryFromOptions} / {@link buildSubquery} exactly so a
967
+ * non-SurrealQL backend produces results identical to the SurrealQL path.
968
+ */
969
+ function buildQueryPlan<TModel extends GenericModel, IsOne extends boolean>(
970
+ tableName: string,
971
+ options: QueryOptions<TModel, IsOne>,
972
+ schema: SchemaStructure
973
+ ): QueryPlan {
974
+ const plan: QueryPlan = { table: tableName };
975
+
976
+ if (options.select && options.select.length > 0 && !options.select.includes('*')) {
977
+ plan.select = options.select.filter((f) => f !== '*') as string[];
978
+ }
979
+
980
+ const parsedWhere = options.where
981
+ ? (parseObjectIdsToRecordId(options.where, tableName) as Record<string, unknown>)
982
+ : undefined;
983
+ if (parsedWhere && Object.keys(parsedWhere).length > 0) {
984
+ // slaveToParams: top-level filters materialize from `params` (the query's
985
+ // identity), not a baked literal — see buildWhereNodes. Prevents a query's
986
+ // rows ever coming from a different query's plan.
987
+ const nodes = buildWhereNodes(parsedWhere, true);
988
+ if (nodes.length > 0) plan.where = nodes;
989
+ }
990
+
991
+ if (options.orderBy && Object.keys(options.orderBy).length > 0) {
992
+ plan.orderBy = Object.entries(options.orderBy).map(
993
+ ([field, direction]) => [field, direction as 'asc' | 'desc']
994
+ );
995
+ }
996
+
997
+ if (options.limit !== undefined) plan.limit = options.limit;
998
+ if (options.offset !== undefined) plan.offset = options.offset;
999
+
1000
+ if (options.related && options.related.length > 0) {
1001
+ plan.relations = options.related.map((rel) => buildRelationPlan(rel, schema));
1002
+ }
1003
+
1004
+ return plan;
1005
+ }
1006
+
1007
+ /**
1008
+ * Engine-neutral counterpart of {@link buildSubquery}. Resolves the same
1009
+ * cardinality / foreign-key / nested-relation metadata but returns a structured
1010
+ * {@link RelationPlan} instead of a SurrealQL subquery string.
1011
+ */
1012
+ function buildRelationPlan(
1013
+ rel: RelatedQuery & { foreignKeyField?: string },
1014
+ schema: SchemaStructure
1015
+ ): RelationPlan {
1016
+ const { relatedTable, alias, modifier, cardinality } = rel;
1017
+ // Same fallback chain as buildSubquery (`rel.foreignKeyField || alias`); the
1018
+ // top-level foreignKeyField is already reverse-resolved by `.related()`.
1019
+ const foreignKeyField = (rel.foreignKeyField || alias || relatedTable) as string;
1020
+
1021
+ const plan: RelationPlan = {
1022
+ alias: (alias || relatedTable) as string,
1023
+ table: relatedTable,
1024
+ cardinality,
1025
+ foreignKeyField,
1026
+ };
1027
+
1028
+ if (modifier) {
1029
+ const modifierBuilder = new SchemaAwareQueryModifierBuilderImpl(relatedTable, schema);
1030
+ modifier(modifierBuilder as any);
1031
+ const subOptions = modifierBuilder._getOptions();
1032
+
1033
+ if (subOptions.select && subOptions.select.length > 0 && !subOptions.select.includes('*')) {
1034
+ plan.select = subOptions.select.filter((f) => f !== '*') as string[];
1035
+ }
1036
+
1037
+ if (subOptions.where && Object.keys(subOptions.where).length > 0) {
1038
+ const parsedSubWhere = parseObjectIdsToRecordId(subOptions.where, relatedTable) as Record<
1039
+ string,
1040
+ unknown
1041
+ >;
1042
+ const nodes = buildWhereNodes(parsedSubWhere);
1043
+ if (nodes.length > 0) plan.where = nodes;
1044
+ }
1045
+
1046
+ if (subOptions.orderBy && Object.keys(subOptions.orderBy).length > 0) {
1047
+ plan.orderBy = Object.entries(subOptions.orderBy).map(
1048
+ ([field, direction]) => [field, direction as 'asc' | 'desc']
1049
+ );
1050
+ }
1051
+
1052
+ if (subOptions.limit !== undefined) plan.limit = subOptions.limit;
1053
+
1054
+ // Nested relations: re-resolve exactly as buildSubquery does — the child's
1055
+ // foreignKeyField comes from the relatedTable-based lookup, not the reverse
1056
+ // heuristic used for top-level relations.
1057
+ if (subOptions.related && subOptions.related.length > 0) {
1058
+ const resolvedNestedRels = subOptions.related.map((nestedRel) => {
1059
+ const relationship = schema.relationships.find(
1060
+ (r) => r.from === relatedTable && r.field === nestedRel.alias
1061
+ );
1062
+ if (relationship) {
1063
+ const nestedForeignKeyField =
1064
+ relationship.cardinality === 'many' ? relatedTable : (nestedRel.alias as string);
1065
+ return {
1066
+ ...nestedRel,
1067
+ relatedTable: relationship.to,
1068
+ cardinality: relationship.cardinality,
1069
+ foreignKeyField: nestedForeignKeyField,
1070
+ } as RelatedQuery & { foreignKeyField: string };
1071
+ }
1072
+ return nestedRel;
1073
+ });
1074
+ plan.relations = resolvedNestedRels.map((nestedRel) => buildRelationPlan(nestedRel, schema));
1075
+ }
1076
+ }
1077
+
1078
+ // one-to-one gets an implicit per-parent LIMIT 1 (matches buildSubquery).
1079
+ if (cardinality === 'one' && plan.limit === undefined) {
1080
+ plan.limit = 1;
1081
+ }
1082
+
1083
+ return plan;
1084
+ }
1085
+
1086
+ /**
1087
+ * Convert a parsed WHERE object (string IDs already → RecordId) into the
1088
+ * engine-neutral {@link WhereNode}[] conjunction. Mirrors the `_or` / comparison
1089
+ * / equality handling in {@link buildQueryFromOptions}. A `$`-prefixed `_val`
1090
+ * becomes a `paramRef` (with the leading `$` stripped).
1091
+ */
1092
+ /**
1093
+ * @param slaveToParams When true, top-level plain/operator-literal comparisons
1094
+ * ALSO carry a `paramRef` equal to the field name — the same var name
1095
+ * `buildQueryFromOptions` binds the value under (`field = $field`). The
1096
+ * engines then materialize by reading `params[field]` (falling back to the
1097
+ * baked `value` when the param is absent), so a query's rows are slaved to
1098
+ * its `params` (its identity) and can never come from a different query's
1099
+ * baked plan. Only safe at the TOP LEVEL, where the field is a schema column
1100
+ * that survives `parseQueryParams` and the caller passes `params`. NOT used
1101
+ * for relation sub-wheres (rendered with a params-less ctx) or `_or` branches
1102
+ * (bound under synthetic `white__or0` names) - those stay baked.
1103
+ */
1104
+ function buildWhereNodes(
1105
+ parsedWhere: Record<string, unknown>,
1106
+ slaveToParams = false
1107
+ ): WhereNode[] {
1108
+ const toComparison = (field: string, value: unknown, slave: boolean): WhereComparison => {
1109
+ if (value && typeof value === 'object' && '_op' in value && '_val' in value) {
1110
+ const { _op, _val, _swap } = value as ComparisonOp;
1111
+ if (typeof _val === 'string' && _val.startsWith('$')) {
1112
+ return { field, op: _op, value: undefined, paramRef: _val.slice(1), swap: _swap };
1113
+ }
1114
+ // Literal operand: keep `value` as a fallback and add `paramRef: field`
1115
+ // (slave mode) so materialization reads the query's own `params[field]`.
1116
+ return slave
1117
+ ? { field, op: _op, value: _val, paramRef: field, swap: _swap }
1118
+ : { field, op: _op, value: _val, swap: _swap };
1119
+ }
1120
+ return slave ? { field, op: '=', value, paramRef: field } : { field, op: '=', value };
1121
+ };
1122
+
1123
+ const nodes: WhereNode[] = [];
1124
+ for (const [key, value] of Object.entries(parsedWhere)) {
1125
+ if (key === '_or' && Array.isArray(value)) {
1126
+ const or: WhereComparison[] = [];
1127
+ for (const branch of value) {
1128
+ if (branch && typeof branch === 'object') {
1129
+ for (const [bField, bVal] of Object.entries(branch as Record<string, unknown>)) {
1130
+ // OR branches bind under synthetic `white__or0` names (see
1131
+ // orParamName / buildQueryFromOptions); the plan keeps them baked.
1132
+ or.push(toComparison(bField, bVal, false));
1133
+ }
1134
+ }
1135
+ }
1136
+ if (or.length > 0) nodes.push({ or });
1137
+ continue;
1138
+ }
1139
+ nodes.push(toComparison(key, value, slaveToParams));
1140
+ }
1141
+ return nodes;
1142
+ }
1143
+
821
1144
  /**
822
1145
  * Build a subquery for a related field
823
1146
  */
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { QueryBuilder, SchemaStructure } from './index';
2
+ import { QueryBuilder } from './index';
3
3
 
4
4
  describe('QueryBuilder Relationship Inference', () => {
5
5
  const schema = {
@@ -1,16 +1,42 @@
1
1
  /**
2
2
  * Supported value types in the schema
3
3
  */
4
- export type ValueType = 'string' | 'number' | 'boolean' | 'null' | 'json';
4
+ export type ValueType = 'string' | 'number' | 'boolean' | 'null' | 'json' | 'Uint8Array';
5
5
 
6
6
  /**
7
7
  * Column metadata defining the type and optionality of a field
8
8
  */
9
+ /**
10
+ * CRDT types supported by Sp00ky's Loro integration
11
+ */
12
+ export type CrdtType = 'text' | 'map' | 'list' | 'counter';
13
+
9
14
  export interface ColumnSchema {
10
15
  readonly type: ValueType;
11
16
  readonly optional: boolean;
12
17
  readonly dateTime?: boolean;
13
18
  readonly recordId?: boolean;
19
+ readonly crdt?: CrdtType;
20
+ readonly cursor?: boolean;
21
+ /** True for `TYPE bytes` columns. Runtime values are `Uint8Array`. */
22
+ readonly bytes?: boolean;
23
+ /**
24
+ * True for `TYPE array<...>` columns. `type` then names the ELEMENT type, so
25
+ * the runtime value is `ElementType[]` (e.g. `array<string>` → `string[]`).
26
+ */
27
+ readonly array?: boolean;
28
+ /**
29
+ * True for `-- @opaque` columns: the value IS synced to the client and can be
30
+ * read from a query result, but the sync engine never stores it server-side.
31
+ *
32
+ * That makes it unusable for anything the server has to evaluate — `where`,
33
+ * `orderBy`, joins, aggregates, table permissions — because the SSP has no
34
+ * value to evaluate against. A predicate on such a column would appear to work
35
+ * locally (the local cache does hold the value) while matching nothing
36
+ * server-side, so the query builder rejects it outright instead of letting the
37
+ * two diverge silently.
38
+ */
39
+ readonly opaque?: boolean;
14
40
  }
15
41
 
16
42
  /**
@@ -62,16 +88,25 @@ export type TypeNameToTypeMap = {
62
88
  boolean: boolean;
63
89
  null: null;
64
90
  json: unknown;
91
+ Uint8Array: Uint8Array;
65
92
  };
66
93
 
94
+ /**
95
+ * The element/base TS type of a column, wrapping in an array for `array: true`
96
+ * columns (where `type` names the element type).
97
+ */
98
+ export type ColumnBaseTSType<T extends ColumnSchema> = T extends { array: true }
99
+ ? TypeNameToTypeMap[T['type']][]
100
+ : TypeNameToTypeMap[T['type']];
101
+
67
102
  /**
68
103
  * Convert a column type to its TypeScript type
69
104
  */
70
105
  export type ColumnToTSType<T extends ColumnSchema> = T extends {
71
106
  optional: true;
72
107
  }
73
- ? TypeNameToTypeMap[T['type']] | null
74
- : TypeNameToTypeMap[T['type']];
108
+ ? ColumnBaseTSType<T> | null
109
+ : ColumnBaseTSType<T>;
75
110
 
76
111
  /**
77
112
  * Helper to extract relationship field names for a table