@spooky-sync/query-builder 0.0.1-canary.38 → 0.0.1-canary.381

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,66 @@ 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?.ephemeral) {
56
+ throw new Error(
57
+ `Cannot use '${field}' in ${clause}(): it is marked '-- @ephemeral' on ` +
58
+ `${tableName}, so it only travels between peers and the database never ` +
59
+ `holds it. Read the field from query results instead.`
60
+ );
61
+ }
62
+ if (column?.opaque) {
63
+ throw new Error(
64
+ `Cannot use '${field}' in ${clause}(): it is marked '-- @opaque' on ` +
65
+ `${tableName}, so the sync engine never stores its value and cannot ` +
66
+ `evaluate it. Read the field from query results instead, or remove ` +
67
+ `'-- @opaque' from the schema if you need to query on it.`
68
+ );
69
+ }
70
+ }
71
+ }
72
+
73
+ /** Every column name a `where` clause touches, including `_or` branches. */
74
+ function whereFieldNames(conditions: Record<string, unknown>): string[] {
75
+ const out: string[] = [];
76
+ for (const [key, value] of Object.entries(conditions)) {
77
+ if (key === '_or') {
78
+ for (const branch of (value ?? []) as Record<string, unknown>[]) {
79
+ out.push(...whereFieldNames(branch ?? {}));
80
+ }
81
+ continue;
82
+ }
83
+ out.push(key);
84
+ }
85
+ return out;
86
+ }
87
+
22
88
  /**
23
89
  * Parse a string ID to RecordId
24
90
  * - If it's in the format "table:id", use it as-is
@@ -156,6 +222,7 @@ export class InnerQuery<
156
222
  return this.executor(this);
157
223
  }
158
224
 
225
+
159
226
  public buildUpdateQuery(patches: any[]): QueryInfo {
160
227
  return buildQueryFromOptions('UPDATE', this._tableName, this.options, this.schema, patches);
161
228
  }
@@ -282,6 +349,11 @@ export class FinalQuery<
282
349
  get hash(): number {
283
350
  return this._innerQuery.hash;
284
351
  }
352
+
353
+ /** Whether `.ignoreUnusedArgs()` was called on the builder. */
354
+ get ignoresUnusedArgs(): boolean {
355
+ return this.options.ignoreUnusedArgs === true;
356
+ }
285
357
  }
286
358
 
287
359
  /**
@@ -300,7 +372,13 @@ class SchemaAwareQueryModifierBuilderImpl<
300
372
  private readonly schema: S
301
373
  ) {}
302
374
 
303
- where(conditions: Partial<TableModel<GetTable<S, TableName>>>): this {
375
+ where(conditions: WhereInput<TableModel<GetTable<S, TableName>>>): this {
376
+ assertNotOpaque(
377
+ this.schema,
378
+ this.tableName,
379
+ 'where',
380
+ whereFieldNames(conditions as Record<string, unknown>)
381
+ );
304
382
  this.options.where = { ...this.options.where, ...conditions };
305
383
  return this;
306
384
  }
@@ -327,6 +405,7 @@ class SchemaAwareQueryModifierBuilderImpl<
327
405
  field: keyof TableModel<GetTable<S, TableName>> & string,
328
406
  direction: 'asc' | 'desc' = 'asc'
329
407
  ): this {
408
+ assertNotOpaque(this.schema, this.tableName, 'orderBy', [field]);
330
409
  this.options.orderBy = {
331
410
  ...this.options.orderBy,
332
411
  [field]: direction,
@@ -366,9 +445,18 @@ class SchemaAwareQueryModifierBuilderImpl<
366
445
  );
367
446
 
368
447
  if (!relationship) {
369
- throw new Error(
370
- `Relationship '${String(relatedField)}' not found for table '${this.tableName}'`
371
- );
448
+ // No such relationship in the client schema — e.g. a table owned by a
449
+ // devOnly backend (the outbox `job`) that a free/Cloudflare deployment
450
+ // never provisions, so codegen omits it + its relationships. Skip the
451
+ // projection instead of throwing, which would take the whole query
452
+ // (and its other `.related()` siblings — author, comments) down.
453
+ // Mirrors the server's "unpermitted subquery → empty" degradation.
454
+ if (typeof console !== 'undefined') {
455
+ console.warn(
456
+ `[sp00ky] .related('${String(relatedField)}') skipped — no such relationship on '${this.tableName}' in the client schema`
457
+ );
458
+ }
459
+ return this as any;
372
460
  }
373
461
 
374
462
  const relatedTable = relationship.to;
@@ -413,8 +501,14 @@ export class QueryBuilder<
413
501
  * Add additional where conditions
414
502
  */
415
503
  where(
416
- conditions: Partial<TableModel<GetTable<S, TableName>>>
504
+ conditions: WhereInput<TableModel<GetTable<S, TableName>>>
417
505
  ): QueryBuilder<S, TableName, R, RelatedFields, IsOne> {
506
+ assertNotOpaque(
507
+ this.schema,
508
+ this.tableName,
509
+ 'where',
510
+ whereFieldNames(conditions as Record<string, unknown>)
511
+ );
418
512
  this.options.where = { ...this.options.where, ...conditions };
419
513
  return this;
420
514
  }
@@ -439,6 +533,7 @@ export class QueryBuilder<
439
533
  field: TableFieldNames<GetTable<S, TableName>>,
440
534
  direction: 'asc' | 'desc' = 'asc'
441
535
  ): QueryBuilder<S, TableName, R, RelatedFields, IsOne> {
536
+ assertNotOpaque(this.schema, this.tableName, 'orderBy', [field as string]);
442
537
  this.options.orderBy = {
443
538
  ...this.options.orderBy,
444
539
  [field]: direction,
@@ -462,6 +557,19 @@ export class QueryBuilder<
462
557
  return this;
463
558
  }
464
559
 
560
+ /**
561
+ * Say that parts of this query are ignored where it is used, on purpose.
562
+ * A channel (`useChannel(query, peers)`) only reads the `where`, so
563
+ * `orderBy`, `limit`, `offset`, `select` and `.one()` do nothing there and
564
+ * draw a warning; this silences it while showing the reader the query
565
+ * carries arguments that do not apply (handy when one query object serves
566
+ * both a list and a channel).
567
+ */
568
+ ignoreUnusedArgs(): QueryBuilder<S, TableName, R, RelatedFields, IsOne> {
569
+ this.options.ignoreUnusedArgs = true;
570
+ return this;
571
+ }
572
+
465
573
  one(): QueryBuilder<S, TableName, R, RelatedFields, true> {
466
574
  return new QueryBuilder<S, TableName, R, RelatedFields, true>(
467
575
  this.schema,
@@ -516,7 +624,15 @@ export class QueryBuilder<
516
624
  );
517
625
 
518
626
  if (!relationship) {
519
- throw new Error(`Relationship '${String(field)}' not found for table '${this.tableName}'`);
627
+ // See the note on the other `.related()` overload: skip an unknown
628
+ // relationship (warn) rather than throwing, so a table absent from the
629
+ // client schema (e.g. the free-plan `job` outbox) can't crash the query.
630
+ if (typeof console !== 'undefined') {
631
+ console.warn(
632
+ `[sp00ky] .related('${String(field)}') skipped — no such relationship on '${this.tableName}' in the client schema`
633
+ );
634
+ }
635
+ return this as any;
520
636
  }
521
637
 
522
638
  // Determine cardinality and modifier based on arguments
@@ -601,6 +717,28 @@ export class QueryBuilder<
601
717
  }
602
718
  }
603
719
 
720
+ /**
721
+ * The surql param name an `_or` branch condition binds under: the branch FIELD,
722
+ * a `__or` marker and the branch's position. The position alone makes it unique
723
+ * within the query; the field prefix is what lets a consumer type the value by
724
+ * looking the column up (see {@link baseFieldOfParam}). Non-identifier
725
+ * characters (a nested path like `database.owner`) are folded to `_` so the
726
+ * result is a legal param name.
727
+ */
728
+ export function orParamName(field: string, index: number): string {
729
+ return `${field.replace(/[^A-Za-z0-9_]/g, '_')}__or${index}`;
730
+ }
731
+
732
+ /**
733
+ * Inverse of {@link orParamName}: the field a param name was built from, or the
734
+ * name itself when it is a plain top-level param (`field = $field`). Lets a
735
+ * consumer resolve `white__or0` back to the `white` column.
736
+ */
737
+ export function baseFieldOfParam(name: string): string {
738
+ const m = /^(.+)__or\d+$/.exec(name);
739
+ return m ? m[1] : name;
740
+ }
741
+
604
742
  export function cyrb53(str: string, seed: number = 0): number {
605
743
  let h1 = 0xdeadbeef ^ seed,
606
744
  h2 = 0x41c6ce57 ^ seed;
@@ -753,32 +891,57 @@ export function buildQueryFromOptions<TModel extends GenericModel, IsOne extends
753
891
  const vars: Record<string, unknown> = {};
754
892
  if (parsedWhere && Object.keys(parsedWhere).length > 0) {
755
893
  const conditions: string[] = [];
756
- for (const [key, value] of Object.entries(parsedWhere)) {
757
- const varName = key;
758
894
 
759
- // Handle operator objects { _op, _val }
895
+ // Build a single condition for `field`, binding its value under `varName`.
896
+ // Supports operator objects `{ _op, _val, _swap }` (e.g. `{ _op: '<=', _val:
897
+ // 5 }`); a `$`-prefixed string `_val` references an existing param verbatim.
898
+ // Plain values mean equality (`field = $varName`).
899
+ const buildCondition = (field: string, value: unknown, varName: string): string => {
760
900
  if (value && typeof value === 'object' && '_op' in value && '_val' in value) {
761
901
  const { _op, _val, _swap } = value as { _op: string; _val: unknown; _swap?: boolean };
762
-
763
- let rightSide = '';
902
+ let rightSide: string;
764
903
  if (typeof _val === 'string' && _val.startsWith('$')) {
765
904
  rightSide = _val;
766
905
  } else {
767
906
  vars[varName] = _val;
768
907
  rightSide = `$${varName}`;
769
908
  }
909
+ return _swap ? `${rightSide} ${_op} ${field}` : `${field} ${_op} ${rightSide}`;
910
+ }
911
+ vars[varName] = value;
912
+ return `${field} = $${varName}`;
913
+ };
770
914
 
771
- if (_swap) {
772
- conditions.push(`${rightSide} ${_op} ${key}`);
773
- } else {
774
- conditions.push(`${key} ${_op} ${rightSide}`);
915
+ for (const [key, value] of Object.entries(parsedWhere)) {
916
+ // OR-group: `{ _or: [ {field: val}, {field: {_op,_val}}, ... ] }` compiles
917
+ // to one parenthesised `(c1 OR c2 ...)` conjunct. Each branch condition gets
918
+ // a unique, position-indexed param name (`white__or0`, `black__or1`, …) so
919
+ // it never collides with a top-level condition on the same field (e.g. a
920
+ // `white = $white` filter alongside an opponent `_or` on white/black) -
921
+ // keeping the surql + vars, and thus the query hash, stable and
922
+ // deterministic. The FIELD is part of the name on purpose: the consumer
923
+ // types a param by looking its name up in the table's columns
924
+ // (`parseQueryParams` in @spooky-sync/core), and a name it cannot resolve
925
+ // used to be dropped from the registration, which left `$or0` unbound and
926
+ // every `_or` query matching nothing.
927
+ if (key === '_or' && Array.isArray(value)) {
928
+ const orParts: string[] = [];
929
+ let i = 0;
930
+ for (const branch of value) {
931
+ if (branch && typeof branch === 'object') {
932
+ for (const [bField, bVal] of Object.entries(branch as Record<string, unknown>)) {
933
+ orParts.push(buildCondition(bField, bVal, orParamName(bField, i++)));
934
+ }
935
+ }
775
936
  }
776
- } else {
777
- vars[varName] = value;
778
- conditions.push(`${key} = $${varName}`);
937
+ if (orParts.length > 0) conditions.push(`(${orParts.join(' OR ')})`);
938
+ continue;
779
939
  }
940
+
941
+ conditions.push(buildCondition(key, value, key));
780
942
  }
781
- query += ` WHERE ${conditions.join(' AND ')}`;
943
+
944
+ if (conditions.length > 0) query += ` WHERE ${conditions.join(' AND ')}`;
782
945
  }
783
946
 
784
947
  // Add PATCH for UPDATE
@@ -817,9 +980,193 @@ export function buildQueryFromOptions<TModel extends GenericModel, IsOne extends
817
980
  0
818
981
  ),
819
982
  vars: Object.keys(vars).length > 0 ? vars : undefined,
983
+ // Engine-neutral plan mirrors the SELECT above for non-SurrealQL backends.
984
+ // Only SELECT carries a plan; the isOne→limit=1 mutation above is already
985
+ // reflected in `options.limit`, so the plan sees it too.
986
+ plan: method === 'SELECT' ? buildQueryPlan(tableName, options, schema) : undefined,
820
987
  };
821
988
  }
822
989
 
990
+ /**
991
+ * Build the engine-neutral {@link QueryPlan} for a SELECT. Mirrors the string
992
+ * assembly in {@link buildQueryFromOptions} / {@link buildSubquery} exactly so a
993
+ * non-SurrealQL backend produces results identical to the SurrealQL path.
994
+ */
995
+ function buildQueryPlan<TModel extends GenericModel, IsOne extends boolean>(
996
+ tableName: string,
997
+ options: QueryOptions<TModel, IsOne>,
998
+ schema: SchemaStructure
999
+ ): QueryPlan {
1000
+ const plan: QueryPlan = { table: tableName };
1001
+
1002
+ if (options.select && options.select.length > 0 && !options.select.includes('*')) {
1003
+ plan.select = options.select.filter((f) => f !== '*') as string[];
1004
+ }
1005
+
1006
+ const parsedWhere = options.where
1007
+ ? (parseObjectIdsToRecordId(options.where, tableName) as Record<string, unknown>)
1008
+ : undefined;
1009
+ if (parsedWhere && Object.keys(parsedWhere).length > 0) {
1010
+ // slaveToParams: top-level filters materialize from `params` (the query's
1011
+ // identity), not a baked literal — see buildWhereNodes. Prevents a query's
1012
+ // rows ever coming from a different query's plan.
1013
+ const nodes = buildWhereNodes(parsedWhere, true);
1014
+ if (nodes.length > 0) plan.where = nodes;
1015
+ }
1016
+
1017
+ if (options.orderBy && Object.keys(options.orderBy).length > 0) {
1018
+ plan.orderBy = Object.entries(options.orderBy).map(
1019
+ ([field, direction]) => [field, direction as 'asc' | 'desc']
1020
+ );
1021
+ }
1022
+
1023
+ if (options.limit !== undefined) plan.limit = options.limit;
1024
+ if (options.offset !== undefined) plan.offset = options.offset;
1025
+
1026
+ if (options.related && options.related.length > 0) {
1027
+ plan.relations = options.related.map((rel) => buildRelationPlan(rel, schema));
1028
+ }
1029
+
1030
+ return plan;
1031
+ }
1032
+
1033
+ /**
1034
+ * Engine-neutral counterpart of {@link buildSubquery}. Resolves the same
1035
+ * cardinality / foreign-key / nested-relation metadata but returns a structured
1036
+ * {@link RelationPlan} instead of a SurrealQL subquery string.
1037
+ */
1038
+ function buildRelationPlan(
1039
+ rel: RelatedQuery & { foreignKeyField?: string },
1040
+ schema: SchemaStructure
1041
+ ): RelationPlan {
1042
+ const { relatedTable, alias, modifier, cardinality } = rel;
1043
+ // Same fallback chain as buildSubquery (`rel.foreignKeyField || alias`); the
1044
+ // top-level foreignKeyField is already reverse-resolved by `.related()`.
1045
+ const foreignKeyField = (rel.foreignKeyField || alias || relatedTable) as string;
1046
+
1047
+ const plan: RelationPlan = {
1048
+ alias: (alias || relatedTable) as string,
1049
+ table: relatedTable,
1050
+ cardinality,
1051
+ foreignKeyField,
1052
+ };
1053
+
1054
+ if (modifier) {
1055
+ const modifierBuilder = new SchemaAwareQueryModifierBuilderImpl(relatedTable, schema);
1056
+ modifier(modifierBuilder as any);
1057
+ const subOptions = modifierBuilder._getOptions();
1058
+
1059
+ if (subOptions.select && subOptions.select.length > 0 && !subOptions.select.includes('*')) {
1060
+ plan.select = subOptions.select.filter((f) => f !== '*') as string[];
1061
+ }
1062
+
1063
+ if (subOptions.where && Object.keys(subOptions.where).length > 0) {
1064
+ const parsedSubWhere = parseObjectIdsToRecordId(subOptions.where, relatedTable) as Record<
1065
+ string,
1066
+ unknown
1067
+ >;
1068
+ const nodes = buildWhereNodes(parsedSubWhere);
1069
+ if (nodes.length > 0) plan.where = nodes;
1070
+ }
1071
+
1072
+ if (subOptions.orderBy && Object.keys(subOptions.orderBy).length > 0) {
1073
+ plan.orderBy = Object.entries(subOptions.orderBy).map(
1074
+ ([field, direction]) => [field, direction as 'asc' | 'desc']
1075
+ );
1076
+ }
1077
+
1078
+ if (subOptions.limit !== undefined) plan.limit = subOptions.limit;
1079
+
1080
+ // Nested relations: re-resolve exactly as buildSubquery does — the child's
1081
+ // foreignKeyField comes from the relatedTable-based lookup, not the reverse
1082
+ // heuristic used for top-level relations.
1083
+ if (subOptions.related && subOptions.related.length > 0) {
1084
+ const resolvedNestedRels = subOptions.related.map((nestedRel) => {
1085
+ const relationship = schema.relationships.find(
1086
+ (r) => r.from === relatedTable && r.field === nestedRel.alias
1087
+ );
1088
+ if (relationship) {
1089
+ const nestedForeignKeyField =
1090
+ relationship.cardinality === 'many' ? relatedTable : (nestedRel.alias as string);
1091
+ return {
1092
+ ...nestedRel,
1093
+ relatedTable: relationship.to,
1094
+ cardinality: relationship.cardinality,
1095
+ foreignKeyField: nestedForeignKeyField,
1096
+ } as RelatedQuery & { foreignKeyField: string };
1097
+ }
1098
+ return nestedRel;
1099
+ });
1100
+ plan.relations = resolvedNestedRels.map((nestedRel) => buildRelationPlan(nestedRel, schema));
1101
+ }
1102
+ }
1103
+
1104
+ // one-to-one gets an implicit per-parent LIMIT 1 (matches buildSubquery).
1105
+ if (cardinality === 'one' && plan.limit === undefined) {
1106
+ plan.limit = 1;
1107
+ }
1108
+
1109
+ return plan;
1110
+ }
1111
+
1112
+ /**
1113
+ * Convert a parsed WHERE object (string IDs already → RecordId) into the
1114
+ * engine-neutral {@link WhereNode}[] conjunction. Mirrors the `_or` / comparison
1115
+ * / equality handling in {@link buildQueryFromOptions}. A `$`-prefixed `_val`
1116
+ * becomes a `paramRef` (with the leading `$` stripped).
1117
+ */
1118
+ /**
1119
+ * @param slaveToParams When true, top-level plain/operator-literal comparisons
1120
+ * ALSO carry a `paramRef` equal to the field name — the same var name
1121
+ * `buildQueryFromOptions` binds the value under (`field = $field`). The
1122
+ * engines then materialize by reading `params[field]` (falling back to the
1123
+ * baked `value` when the param is absent), so a query's rows are slaved to
1124
+ * its `params` (its identity) and can never come from a different query's
1125
+ * baked plan. Only safe at the TOP LEVEL, where the field is a schema column
1126
+ * that survives `parseQueryParams` and the caller passes `params`. NOT used
1127
+ * for relation sub-wheres (rendered with a params-less ctx) or `_or` branches
1128
+ * (bound under synthetic `white__or0` names) - those stay baked.
1129
+ */
1130
+ function buildWhereNodes(
1131
+ parsedWhere: Record<string, unknown>,
1132
+ slaveToParams = false
1133
+ ): WhereNode[] {
1134
+ const toComparison = (field: string, value: unknown, slave: boolean): WhereComparison => {
1135
+ if (value && typeof value === 'object' && '_op' in value && '_val' in value) {
1136
+ const { _op, _val, _swap } = value as ComparisonOp;
1137
+ if (typeof _val === 'string' && _val.startsWith('$')) {
1138
+ return { field, op: _op, value: undefined, paramRef: _val.slice(1), swap: _swap };
1139
+ }
1140
+ // Literal operand: keep `value` as a fallback and add `paramRef: field`
1141
+ // (slave mode) so materialization reads the query's own `params[field]`.
1142
+ return slave
1143
+ ? { field, op: _op, value: _val, paramRef: field, swap: _swap }
1144
+ : { field, op: _op, value: _val, swap: _swap };
1145
+ }
1146
+ return slave ? { field, op: '=', value, paramRef: field } : { field, op: '=', value };
1147
+ };
1148
+
1149
+ const nodes: WhereNode[] = [];
1150
+ for (const [key, value] of Object.entries(parsedWhere)) {
1151
+ if (key === '_or' && Array.isArray(value)) {
1152
+ const or: WhereComparison[] = [];
1153
+ for (const branch of value) {
1154
+ if (branch && typeof branch === 'object') {
1155
+ for (const [bField, bVal] of Object.entries(branch as Record<string, unknown>)) {
1156
+ // OR branches bind under synthetic `white__or0` names (see
1157
+ // orParamName / buildQueryFromOptions); the plan keeps them baked.
1158
+ or.push(toComparison(bField, bVal, false));
1159
+ }
1160
+ }
1161
+ }
1162
+ if (or.length > 0) nodes.push({ or });
1163
+ continue;
1164
+ }
1165
+ nodes.push(toComparison(key, value, slaveToParams));
1166
+ }
1167
+ return nodes;
1168
+ }
1169
+
823
1170
  /**
824
1171
  * Build a subquery for a related field
825
1172
  */
@@ -1,16 +1,47 @@
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
+ /** Document shape of a `-- @crdt <kind>` field: a plain `LoroText`, or a
10
+ * loro-prosemirror tree. The column itself holds the server-written text. */
11
+ export type CrdtType = 'text' | 'prosemirror';
12
+
9
13
  export interface ColumnSchema {
10
14
  readonly type: ValueType;
11
15
  readonly optional: boolean;
12
16
  readonly dateTime?: boolean;
13
17
  readonly recordId?: boolean;
18
+ readonly crdt?: CrdtType;
19
+ readonly cursor?: boolean;
20
+ /** True for `TYPE bytes` columns. Runtime values are `Uint8Array`. */
21
+ readonly bytes?: boolean;
22
+ /**
23
+ * True for `TYPE array<...>` columns. `type` then names the ELEMENT type, so
24
+ * the runtime value is `ElementType[]` (e.g. `array<string>` → `string[]`).
25
+ */
26
+ readonly array?: boolean;
27
+ /**
28
+ * True for `-- @opaque` columns: the value IS synced to the client and can be
29
+ * read from a query result, but the sync engine never stores it server-side.
30
+ *
31
+ * That makes it unusable for anything the server has to evaluate — `where`,
32
+ * `orderBy`, joins, aggregates, table permissions — because the SSP has no
33
+ * value to evaluate against. A predicate on such a column would appear to work
34
+ * locally (the local cache does hold the value) while matching nothing
35
+ * server-side, so the query builder rejects it outright instead of letting the
36
+ * two diverge silently.
37
+ */
38
+ readonly opaque?: boolean;
39
+ /**
40
+ * True for `-- @ephemeral` columns of a `-- @channel` table: the value only
41
+ * travels between clients over peer to peer channels. It is never written to
42
+ * the database, so like `opaque` it cannot be filtered or ordered on.
43
+ */
44
+ readonly ephemeral?: boolean;
14
45
  }
15
46
 
16
47
  /**
@@ -62,16 +93,25 @@ export type TypeNameToTypeMap = {
62
93
  boolean: boolean;
63
94
  null: null;
64
95
  json: unknown;
96
+ Uint8Array: Uint8Array;
65
97
  };
66
98
 
99
+ /**
100
+ * The element/base TS type of a column, wrapping in an array for `array: true`
101
+ * columns (where `type` names the element type).
102
+ */
103
+ export type ColumnBaseTSType<T extends ColumnSchema> = T extends { array: true }
104
+ ? TypeNameToTypeMap[T['type']][]
105
+ : TypeNameToTypeMap[T['type']];
106
+
67
107
  /**
68
108
  * Convert a column type to its TypeScript type
69
109
  */
70
110
  export type ColumnToTSType<T extends ColumnSchema> = T extends {
71
111
  optional: true;
72
112
  }
73
- ? TypeNameToTypeMap[T['type']] | null
74
- : TypeNameToTypeMap[T['type']];
113
+ ? ColumnBaseTSType<T> | null
114
+ : ColumnBaseTSType<T>;
75
115
 
76
116
  /**
77
117
  * Helper to extract relationship field names for a table
@@ -121,6 +161,8 @@ export interface BucketDefinitionSchema {
121
161
  readonly maxSize?: number;
122
162
  readonly allowedExtensions?: readonly string[];
123
163
  readonly pathPrefixAuth?: boolean;
164
+ /** `'r2'`: the files live with the SSP, not in SurrealDB (`BACKEND "r2"`). */
165
+ readonly backend?: 'r2';
124
166
  }
125
167
 
126
168
  // ============================================================================
@@ -135,6 +177,8 @@ export interface SchemaStructure {
135
177
  readonly name: string;
136
178
  readonly columns: Record<string, ColumnSchema>;
137
179
  readonly primaryKey: readonly string[];
180
+ /** `-- @channel`: queries on this table may open peer to peer channels. */
181
+ readonly channel?: boolean;
138
182
  }[];
139
183
  readonly relationships: readonly {
140
184
  readonly from: string;
@@ -145,6 +189,18 @@ export interface SchemaStructure {
145
189
  readonly backends: Record<string, HTTPOutboxBackendDefinition>;
146
190
  readonly access?: Record<string, AccessDefinition>;
147
191
  readonly buckets?: readonly BucketDefinitionSchema[];
192
+ /**
193
+ * Server-side policy the CLI codegen copies from `sp00ky.yml` into the
194
+ * generated `schema` constant. `queryAllowlist` mirrors `sync.queryAllowlist`:
195
+ * `warn` / `enforce` mean the SSP only accepts query shapes collected at
196
+ * `spky generate` time, so the client refuses raw remote calls unless
197
+ * `allowRawRemote` is set. `impersonation` mirrors `impersonation.enabled`;
198
+ * the server enforces it, the client only uses it to offer the feature.
199
+ */
200
+ readonly policy?: {
201
+ readonly queryAllowlist?: 'off' | 'warn' | 'enforce';
202
+ readonly impersonation?: boolean;
203
+ };
148
204
  }
149
205
 
150
206
  export interface HTTPOutboxBackendDefinition {
@@ -236,6 +292,9 @@ export type GetTable<S extends SchemaStructure, Name extends TableNames<S>> = Ex
236
292
  */
237
293
  export type TableNames<S extends SchemaStructure> = S['tables'][number]['name'];
238
294
 
295
+ /** Names of the tables marked `-- @channel` (peer to peer channels may carry them). */
296
+ export type ChannelTableNames<S extends SchemaStructure> = Extract<S['tables'][number], { channel: true }>['name'];
297
+
239
298
  /**
240
299
  * Extract all field names from a type
241
300
  */