@syncular/server 0.15.21 → 0.15.23

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.
@@ -13,6 +13,42 @@ export class StorageConstraintError extends Error {
13
13
  }
14
14
  }
15
15
 
16
+ /** Stable, privacy-safe failures for trusted server storage queries. */
17
+ export type StorageQueryErrorCode =
18
+ | 'sync.storage.scan_requires_scope'
19
+ | 'sync.storage.index_not_found'
20
+ | 'sync.storage.index_not_materialized'
21
+ | 'sync.storage.index_value_count_mismatch'
22
+ | 'sync.storage.invalid_limit';
23
+
24
+ const STORAGE_QUERY_MESSAGES: Readonly<Record<StorageQueryErrorCode, string>> =
25
+ {
26
+ 'sync.storage.scan_requires_scope':
27
+ 'scope-indexed row scans require at least one scope variable',
28
+ 'sync.storage.index_not_found':
29
+ 'trusted row lookup requires a declared relational index',
30
+ 'sync.storage.index_not_materialized':
31
+ 'trusted row lookup requires a materialized relational table',
32
+ 'sync.storage.index_value_count_mismatch':
33
+ 'trusted row lookup requires one exact value per index column',
34
+ 'sync.storage.invalid_limit':
35
+ 'trusted row lookup limit must be an integer from 1 through 1,000',
36
+ };
37
+
38
+ /**
39
+ * Host-only query error. Messages never include identifiers, values, SQL,
40
+ * paths, or row data; callers branch on `code`, never message text.
41
+ */
42
+ export class StorageQueryError extends Error {
43
+ override readonly name = 'StorageQueryError';
44
+ readonly code: StorageQueryErrorCode;
45
+
46
+ constructor(code: StorageQueryErrorCode) {
47
+ super(STORAGE_QUERY_MESSAGES[code]);
48
+ this.code = code;
49
+ }
50
+ }
51
+
16
52
  interface DriverError {
17
53
  readonly code?: unknown;
18
54
  readonly errno?: unknown;
@@ -0,0 +1,48 @@
1
+ import type { ScopeMap } from '@syncular/core';
2
+ import type { CompiledTable, IndexSchema } from './schema';
3
+ import type { IndexRowScanQuery, RowScanQuery } from './storage';
4
+ import { StorageQueryError } from './storage-errors';
5
+
6
+ /** Fail loudly instead of making an unsupported unscoped scan look empty. */
7
+ export function assertScopeIndexedScan(query: RowScanQuery): void {
8
+ const scopeFilter = (
9
+ query as RowScanQuery & { readonly scopeFilter?: ScopeMap | null }
10
+ ).scopeFilter;
11
+ if (
12
+ scopeFilter === undefined ||
13
+ scopeFilter === null ||
14
+ Object.keys(scopeFilter).length === 0
15
+ ) {
16
+ throw new StorageQueryError('sync.storage.scan_requires_scope');
17
+ }
18
+ }
19
+
20
+ /** Validate and resolve one exact trusted-host relational index lookup. */
21
+ export function resolveIndexRowScan(
22
+ table: CompiledTable,
23
+ query: IndexRowScanQuery,
24
+ ): IndexSchema {
25
+ if (
26
+ !Number.isInteger(query.limit) ||
27
+ query.limit < 1 ||
28
+ query.limit > 1_000
29
+ ) {
30
+ throw new StorageQueryError('sync.storage.invalid_limit');
31
+ }
32
+ if (!table.materialize) {
33
+ throw new StorageQueryError('sync.storage.index_not_materialized');
34
+ }
35
+ const index = table.indexes.find(
36
+ (candidate) => candidate.name === query.index,
37
+ );
38
+ if (index === undefined) {
39
+ throw new StorageQueryError('sync.storage.index_not_found');
40
+ }
41
+ if (
42
+ !Array.isArray(query.values) ||
43
+ query.values.length !== index.columns.length
44
+ ) {
45
+ throw new StorageQueryError('sync.storage.index_value_count_mismatch');
46
+ }
47
+ return index;
48
+ }
package/src/storage.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  * The interface is async throughout so a Postgres implementation slots in
17
17
  * without touching the core. All methods are partition-local (§2.1).
18
18
  */
19
- import type { PushOperationResult, ScopeMap } from '@syncular/core';
19
+ import type { PushOperationResult, RowValue, ScopeMap } from '@syncular/core';
20
20
  import type { CompiledSchema } from './schema';
21
21
 
22
22
  /** The current stored state of a synced row. */
@@ -70,6 +70,10 @@ export interface StoredPushResult {
70
70
  readonly status: 'applied' | 'rejected';
71
71
  /** Present iff `status` is `applied`. */
72
72
  readonly commitSeq?: number;
73
+ /** Host clock when this terminal idempotency outcome was first recorded. */
74
+ readonly recordedAtMs?: number;
75
+ /** Privacy-safe identity used to distinguish this stored outcome from a race. */
76
+ readonly cacheIdentity?: string;
73
77
  readonly results: readonly PushOperationResult[];
74
78
  }
75
79
 
@@ -113,6 +117,26 @@ export interface RowScanQuery {
113
117
  readonly limit: number;
114
118
  }
115
119
 
120
+ /**
121
+ * Exact server-host lookup through one declared relational index.
122
+ *
123
+ * This is deliberately NOT a Syncular scope or client query. It is available
124
+ * only to trusted server code that already owns a `ServerStorage` or
125
+ * `StorageTransaction` capability. Every declared index column must have one
126
+ * exact value, so adapters can keep the lookup bounded and deterministic.
127
+ */
128
+ export interface IndexRowScanQuery {
129
+ readonly table: string;
130
+ /** `TableSchema.indexes[].name`; never exposed as a client subscription. */
131
+ readonly index: string;
132
+ /** Exact values in the index declaration's column order. */
133
+ readonly values: readonly RowValue[];
134
+ /** Resume after this rowId (exclusive); `null` = start of the match set. */
135
+ readonly afterRowId?: string | null;
136
+ /** Integer from 1 through 1,000. */
137
+ readonly limit: number;
138
+ }
139
+
116
140
  export interface ClientCursorInfo {
117
141
  readonly clientId: string;
118
142
  readonly cursor: number;
@@ -172,6 +196,12 @@ export interface StorageTransaction {
172
196
  * semantics. A custom backend may omit it until `commitValidator` is used.
173
197
  */
174
198
  scanRows?(query: RowScanQuery): Promise<StoredRow[]>;
199
+ /**
200
+ * Optional additive capability for trusted authoritative commands. In-tree
201
+ * SQLite/PostgreSQL/D1 adapters implement it with transaction-local
202
+ * read-your-own-writes semantics. It is not reachable from SSP2 requests.
203
+ */
204
+ scanRowsByIndex?(query: IndexRowScanQuery): Promise<StoredRow[]>;
175
205
  /**
176
206
  * Serialize candidate-state validation for this partition before any row
177
207
  * read/write. Required at runtime when `commitValidator` is configured.
@@ -279,6 +309,16 @@ export interface ServerStorage {
279
309
  /** Scope-filtered snapshot scan, ordered by rowId (bootstrap paging). */
280
310
  scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]>;
281
311
 
312
+ /**
313
+ * Optional trusted-host exact lookup through a declared relational index.
314
+ * This capability is outside client scope/subscription authorization and
315
+ * MUST NOT be re-exported as a client-controlled endpoint.
316
+ */
317
+ scanRowsByIndex?(
318
+ partition: string,
319
+ query: IndexRowScanQuery,
320
+ ): Promise<StoredRow[]>;
321
+
282
322
  getClientRecord(
283
323
  partition: string,
284
324
  clientId: string,