orez-sync-cf-host 0.10.7 → 0.11.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.
package/dist/host.js CHANGED
@@ -2,6 +2,7 @@ import { DurableObject } from 'cloudflare:workers';
2
2
  import { createSyncExecutor } from 'orez-sync-executor/core';
3
3
  import { validatePullCaps, validateSyncHostConfig } from './config.js';
4
4
  import { createQueryCompiler } from './query-compiler.js';
5
+ import { resolveQueryPatch } from './query-patch.js';
5
6
  import { decodeSqlParams, SqlStorageDirect, SqlStorageMutatorTransaction, SqlStorageSyncDb, } from './sql-storage-adapter.js';
6
7
  import { engine_apply_snapshot_changes, engine_apply_snapshot_page, engine_apply_upstream, engine_begin_snapshot_generation, engine_finalize, engine_finalize_snapshot_generation, engine_handle_pull, engine_handle_query_pull, engine_init_query_schema, engine_init_schema, engine_invalidate, engine_memory_bytes, engine_preflight, engine_prune, engine_push_validate, engine_read_snapshot_progress, engine_state, engine_version, } from './wasm.js';
7
8
  import { IngestBreakerError, IngestCircuitBreaker, retryDelayMs, shouldRetryDelegatedPush, } from './write-safeguards.js';
@@ -344,7 +345,7 @@ export function createSyncDurableObject(config) {
344
345
  this.#ingestBreaker.restore(ingestBreakerReason, Number(this.#controlGet('ingestBreakerRetryAt')), Number(this.#controlGet('ingestBreakerTrips')));
345
346
  }
346
347
  this.#wasm(() => engine_init_schema(this.#engineDb, config.schema));
347
- if (config.queryAware || config.resolveQuery)
348
+ if (config.queryAware || config.resolveQueries)
348
349
  this.#wasm(() => engine_init_query_schema(this.#engineDb));
349
350
  });
350
351
  });
@@ -952,7 +953,7 @@ export function createSyncDurableObject(config) {
952
953
  const queryAware = this.#queryAwareOverride() ??
953
954
  (typeof config.queryAware === 'function'
954
955
  ? config.queryAware(claims)
955
- : (config.queryAware ?? Boolean(config.resolveQuery)));
956
+ : (config.queryAware ?? Boolean(config.resolveQueries)));
956
957
  const transformVersion = queryAware
957
958
  ? typeof config.queryTransformVersion === 'function'
958
959
  ? config.queryTransformVersion(claims)
@@ -961,7 +962,16 @@ export function createSyncDurableObject(config) {
961
962
  if (!Number.isSafeInteger(transformVersion) || transformVersion < 0) {
962
963
  throw new TypeError('queryTransformVersion must be a non-negative safe integer');
963
964
  }
964
- const releaseQueryPull = queryAware && config.resolveQuery
965
+ // The group lock exists to keep two desired-query patches from being
966
+ // applied out of arrival order when one of them is slow to resolve.
967
+ // A pull carrying no patch applies nothing, and it reads membership
968
+ // inside transactionSync, which is already atomic against whichever
969
+ // patch commits around it. Holding the lock for those pulls only makes
970
+ // an unchanged warm pull wait out somebody else's query-resolution round
971
+ // trip to the application, which is the head-of-line blocking behind
972
+ // the pull tail: a client polls far more often than it changes what it
973
+ // wants, so almost every pull in flight is patch-free.
974
+ const releaseQueryPull = queryAware && config.resolveQueries && body.queries
965
975
  ? await this.#acquireQueryPullLock(typeof body.clientGroupID === 'string' ? body.clientGroupID : '')
966
976
  : null;
967
977
  let response;
@@ -969,43 +979,12 @@ export function createSyncDurableObject(config) {
969
979
  if (queryAware && body.queries) {
970
980
  const queries = body.queries;
971
981
  if (Array.isArray(queries.patch)) {
972
- const patch = [];
973
- for (const operation of queries.patch) {
974
- if (!operation || typeof operation !== 'object') {
975
- patch.push(operation);
976
- continue;
977
- }
978
- const op = operation;
979
- if (op.op === 'put') {
980
- if (!config.resolveQuery || typeof op.name !== 'string') {
981
- throw requestError('query put requires a server-resolved named query');
982
- }
983
- if (!Array.isArray(op.args)) {
984
- throw requestError('named query args must be an array');
985
- }
986
- const args = op.args;
987
- let ast;
988
- try {
989
- // resolveQuery may be async and needs `env` (a consumer can
990
- // delegate the transform to its app's real synced-queries
991
- // endpoint over an app service binding — authenticate runs in the
992
- // worker isolate, but the query loop runs here in the DO, so the
993
- // binding must come from the DO's own env, not a shared global).
994
- ast = await config.resolveQuery(op.name, args, claims, this.env);
995
- }
996
- catch (error) {
997
- throw requestError(`unknown or unsupported named query: ${op.name}`);
998
- }
999
- patch.push({
1000
- op: 'put',
1001
- hash: op.hash,
1002
- ast,
1003
- transformVersion,
1004
- });
1005
- }
1006
- else
1007
- patch.push(operation);
1008
- }
982
+ // resolveQueries may be async and needs `env` (a consumer can
983
+ // delegate the transform to its app's real synced-queries
984
+ // endpoint over an app service binding — authenticate runs in the
985
+ // worker isolate, but the query loop runs here in the DO, so the
986
+ // binding must come from the DO's own env, not a shared global).
987
+ const patch = await resolveQueryPatch(queries.patch, (requests) => config.resolveQueries(requests, claims, this.env), transformVersion, requestError);
1009
988
  body = { ...body, queries: { ...queries, patch } };
1010
989
  }
1011
990
  }
package/dist/index.d.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  export { createSyncDurableObject, createSyncWorker } from './host.js';
2
2
  export { createQueryCompiler } from './query-compiler.js';
3
+ export { resolveQueryPatch } from './query-patch.js';
3
4
  export { visibility } from './types.js';
4
5
  export { DEFAULT_TRANSACTION_QUERY_BUDGET, TransactionQueryBudgetError, executeTransactionQueryPlan, executeTransactionQueryPlanAsync, } from './transaction-query.js';
5
- export type { PullCaps, QueryResolver, SyncHostConfig, SyncHostEnv, ServiceBinding, SyncSql, UpstreamConfig, VisibilityConfig, VisibilityExpression, VisibilityFilter, VisibilityOperand, VisibilityValue, } from './types.js';
6
+ export type { PullCaps, QueryResolution, QueryResolutionRequest, QueryResolver, SyncHostConfig, SyncHostEnv, ServiceBinding, SyncSql, UpstreamConfig, VisibilityConfig, VisibilityExpression, VisibilityFilter, VisibilityOperand, VisibilityValue, } from './types.js';
6
7
  export type { SyncDurableObjectConstructor } from './host.js';
7
8
  export type { CompiledTransactionQueryNode, CompiledTransactionQueryPlan, CompiledTransactionQueryRelationship, TransactionQueryBinding, TransactionQueryBudget, TransactionQueryColumn, TransactionQueryColumnType, TransactionQueryExecutionOptions, TransactionQueryWireValue, } from './transaction-query.js';
8
9
  export type { TransactionQueryCompiler } from './query-compiler.js';
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { createSyncDurableObject, createSyncWorker } from './host.js';
2
2
  export { createQueryCompiler } from './query-compiler.js';
3
+ export { resolveQueryPatch } from './query-patch.js';
3
4
  export { visibility } from './types.js';
4
5
  export { DEFAULT_TRANSACTION_QUERY_BUDGET, TransactionQueryBudgetError, executeTransactionQueryPlan, executeTransactionQueryPlanAsync, } from './transaction-query.js';
@@ -0,0 +1,29 @@
1
+ import type { JsonValue } from 'orez-sync-executor';
2
+ export type QueryResolutionRequest = {
3
+ readonly name: string;
4
+ readonly args: readonly JsonValue[];
5
+ };
6
+ /**
7
+ * One resolved query, positionally matched to the request at the same index.
8
+ *
9
+ * An `error` fails only its own query rather than the whole patch, so a client
10
+ * that registers one unknown query alongside nine good ones is told which one
11
+ * is unknown.
12
+ */
13
+ export type QueryResolution = {
14
+ readonly ast: JsonValue;
15
+ } | {
16
+ readonly error: string;
17
+ };
18
+ /**
19
+ * Turn a client's desired-query patch into the resolved patch sync-core takes.
20
+ *
21
+ * Both hosts share this because both must agree on exactly one thing: how many
22
+ * times a consumer's resolver is called for one patch. Resolving per query made
23
+ * a pull cost one application round trip per registered query, each one
24
+ * re-authenticating, so a screen mounting eleven views paid eleven of them in
25
+ * series. Everything else here exists to make the single call safe: the patch
26
+ * keeps its order, duplicate (name, args) pairs collapse to one request and fan
27
+ * back out, and a resolver error names the query it came from.
28
+ */
29
+ export declare function resolveQueryPatch(patch: readonly unknown[], resolve: (requests: readonly QueryResolutionRequest[]) => readonly QueryResolution[] | Promise<readonly QueryResolution[]>, transformVersion: number, fail: (message: string) => Error): Promise<unknown[]>;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Turn a client's desired-query patch into the resolved patch sync-core takes.
3
+ *
4
+ * Both hosts share this because both must agree on exactly one thing: how many
5
+ * times a consumer's resolver is called for one patch. Resolving per query made
6
+ * a pull cost one application round trip per registered query, each one
7
+ * re-authenticating, so a screen mounting eleven views paid eleven of them in
8
+ * series. Everything else here exists to make the single call safe: the patch
9
+ * keeps its order, duplicate (name, args) pairs collapse to one request and fan
10
+ * back out, and a resolver error names the query it came from.
11
+ */
12
+ export async function resolveQueryPatch(patch, resolve, transformVersion, fail) {
13
+ const puts = [];
14
+ const requests = [];
15
+ const requestByKey = new Map();
16
+ patch.forEach((operation, index) => {
17
+ if (!operation || typeof operation !== 'object')
18
+ return;
19
+ const op = operation;
20
+ if (op.op !== 'put')
21
+ return;
22
+ if (typeof op.name !== 'string') {
23
+ throw fail('query put requires a server-resolved named query');
24
+ }
25
+ if (!Array.isArray(op.args))
26
+ throw fail('named query args must be an array');
27
+ const key = JSON.stringify([op.name, op.args]);
28
+ if (!requestByKey.has(key)) {
29
+ requestByKey.set(key, requests.length);
30
+ requests.push({ name: op.name, args: op.args });
31
+ }
32
+ puts.push({ index, key, name: op.name, hash: op.hash });
33
+ });
34
+ if (puts.length === 0)
35
+ return [...patch];
36
+ const resolved = await resolve(requests);
37
+ if (resolved.length !== requests.length) {
38
+ throw new Error(`query resolver returned ${resolved.length} results for ${requests.length} queries`);
39
+ }
40
+ const result = [...patch];
41
+ for (const put of puts) {
42
+ const resolution = resolved[requestByKey.get(put.key)];
43
+ if (!resolution || 'error' in resolution) {
44
+ throw fail(`unknown or unsupported named query: ${put.name}`);
45
+ }
46
+ result[put.index] = {
47
+ op: 'put',
48
+ hash: put.hash,
49
+ ast: resolution.ast,
50
+ transformVersion,
51
+ };
52
+ }
53
+ return result;
54
+ }
package/dist/types.d.ts CHANGED
@@ -1,13 +1,28 @@
1
+ import type { QueryResolution, QueryResolutionRequest } from './query-patch.js';
1
2
  import type { TransactionQueryBudget } from './transaction-query.js';
2
3
  import type { Schema } from '@rocicorp/zero';
3
- import type { ExecResult, JsonValue, MutatorRegistry, NormalizedClaims, SqlStatementMetadata, VisibilityConfig } from 'orez-sync-executor';
4
+ import type { ExecResult, MutatorRegistry, NormalizedClaims, SqlStatementMetadata, VisibilityConfig } from 'orez-sync-executor';
4
5
  export { visibility, type VisibilityExpression, type VisibilityFilter, type VisibilityOperand, type VisibilityValue, } from './visibility.js';
5
6
  export type { VisibilityConfig } from 'orez-sync-executor';
7
+ export type { QueryResolution, QueryResolutionRequest } from './query-patch.js';
6
8
  export interface SyncSql {
7
9
  exec(sql: string, params?: readonly unknown[], metadata?: SqlStatementMetadata): ExecResult;
8
10
  query<Row extends Record<string, unknown> = Record<string, unknown>>(sql: string, params?: readonly unknown[]): Row[];
9
11
  }
10
- export type QueryResolver = (name: string, args: readonly JsonValue[], claims: NormalizedClaims, env: SyncHostEnv) => JsonValue | Promise<JsonValue>;
12
+ /**
13
+ * Resolve a whole desired-query patch in one call.
14
+ *
15
+ * This is a batch contract because it is nearly always a network call: a
16
+ * consumer delegates the transform to its application, which authenticates and
17
+ * answers over a service binding. Resolving one query per call made a pull's
18
+ * cost linear in the number of queries a screen registers, and each round trip
19
+ * re-authenticated. Measured against production with a captured mobile client's
20
+ * desired-query set: an 11-query pull took 23.3 s against 2.0 s for the same
21
+ * pull carrying no queries.
22
+ *
23
+ * Implementations must return exactly one entry per request, in request order.
24
+ */
25
+ export type QueryResolver = (requests: readonly QueryResolutionRequest[], claims: NormalizedClaims, env: SyncHostEnv) => readonly QueryResolution[] | Promise<readonly QueryResolution[]>;
11
26
  export type PullCaps = {
12
27
  maxChangeRows: number;
13
28
  maxChangeBytes: number;
@@ -85,7 +100,8 @@ export type SyncHostConfig<Env extends SyncHostEnv = SyncHostEnv, S extends Sche
85
100
  /** Enable desired-query pulls for this namespace and resolve named queries
86
101
  * into validated Zero ASTs before they reach sync-core. */
87
102
  queryAware?: boolean | ((claims: NormalizedClaims) => boolean);
88
- resolveQuery?: QueryResolver;
103
+ /** Resolve every named query in one desired-query patch, in one call. */
104
+ resolveQueries?: QueryResolver;
89
105
  /** Server-owned invalidation epoch for permission/schema transforms. */
90
106
  queryTransformVersion?: number | ((claims: NormalizedClaims) => number);
91
107
  /** Enable consumer visibility from the first request. Defaults to false for harnesses. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orez-sync-cf-host",
3
- "version": "0.10.7",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -19,6 +19,10 @@
19
19
  "types": "./dist/query-compiler.d.ts",
20
20
  "import": "./dist/query-compiler.js"
21
21
  },
22
+ "./query-patch": {
23
+ "types": "./dist/query-patch.d.ts",
24
+ "import": "./dist/query-patch.js"
25
+ },
22
26
  "./transaction-query": {
23
27
  "types": "./dist/transaction-query.d.ts",
24
28
  "import": "./dist/transaction-query.js"
@@ -56,7 +60,7 @@
56
60
  "typecheck": "bun run build:dist:platform && tsc --noEmit"
57
61
  },
58
62
  "dependencies": {
59
- "orez-sync-executor": "0.10.7"
63
+ "orez-sync-executor": "0.11.0"
60
64
  },
61
65
  "devDependencies": {
62
66
  "@cloudflare/workers-types": "4.20260617.1",