orez-sync-cf-host 0.5.16 → 0.5.18

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.
@@ -32,15 +32,19 @@ const numericTextNamespaces = new Set<string>()
32
32
  const jsonValueNamespaces = new Set<string>()
33
33
  const hydratedNamespaces = new Set<string>()
34
34
  const heldSnapshots = new Set<string>()
35
+ const holdSnapshotsAfterCursor = new Set<string>()
35
36
  const activeSnapshots = new Set<string>()
37
+ const snapshotLimits = new Map<string, number[]>()
36
38
  let delegatedFailuresRemaining = 0
37
39
  let delegatedAttempts = 0
38
40
  let delegatedPushFailedRemaining = 0
41
+ let delegatedUrl = ''
39
42
 
40
43
  const config: SyncHostConfig<Env> = {
41
44
  hostVersion: 'upstream-ingest-harness',
42
45
  schema,
43
- mutateUrl: '/api/zero/push',
46
+ mutateUrl: '/api/zero/push?schema=feed_0&appID=feed',
47
+ mutateOrigin: 'https://app.internal',
44
48
  mutateBinding: 'APP',
45
49
  delegatedPushRetry: {
46
50
  maxAttempts: 3,
@@ -50,9 +54,11 @@ const config: SyncHostConfig<Env> = {
50
54
  },
51
55
  upstream: {
52
56
  binding: 'DATA',
53
- namespacePath: (namespace) => `/${namespace}`,
57
+ namespacePath: (namespace) =>
58
+ namespace.startsWith('root-mount-') ? '/' : `/${namespace}`,
54
59
  changeLimit: 2,
55
60
  intervalMs: 1_000,
61
+ ingestBudgetRows: 600,
56
62
  },
57
63
  initialize(sql) {
58
64
  sql.exec(
@@ -63,6 +69,12 @@ const config: SyncHostConfig<Env> = {
63
69
  const userID = request.headers.get('authorization')?.match(/^Bearer token-(.+)$/)?.[1]
64
70
  return userID ? { userID } : null
65
71
  },
72
+ authorizeWake(request) {
73
+ return new URL(request.url).searchParams.get('wakeToken') === 'ingest-harness-wake'
74
+ },
75
+ authorizeNotify(request, env) {
76
+ return Boolean(env.ADMIN_KEY) && request.headers.get('x-admin-key') === env.ADMIN_KEY
77
+ },
66
78
  namespace(request) {
67
79
  return new URL(request.url).pathname.split('/')[1] || null
68
80
  },
@@ -113,6 +125,35 @@ export class DataService extends WorkerEntrypoint<Env> {
113
125
  new Response('DATA route rejected non-feed request', { status: 418 })
114
126
  )
115
127
  }
128
+ if (pathname === '/changes') {
129
+ const cursor = Number(url.searchParams.get('watermark') ?? 0)
130
+ return Promise.resolve(
131
+ Response.json({
132
+ watermark: 1,
133
+ changes:
134
+ cursor >= 1
135
+ ? []
136
+ : [
137
+ {
138
+ watermark: 1,
139
+ tableName: 'item',
140
+ op: 'INSERT',
141
+ rowData: {
142
+ id: 'root-feed-row',
143
+ label: 'root-mounted upstream feed',
144
+ rank: 1,
145
+ done: false,
146
+ meta: null,
147
+ },
148
+ oldData: null,
149
+ },
150
+ ],
151
+ })
152
+ )
153
+ }
154
+ if (pathname === '/_orez/write-budget') {
155
+ return Promise.resolve(Response.json({ enabled: true, rootMount: true }))
156
+ }
116
157
  const namespace = pathname.split('/')[1] ?? ''
117
158
  if (pathname.endsWith('/changes') && jsonValueNamespaces.has(namespace)) {
118
159
  const cursor = Number(new URL(request.url).searchParams.get('watermark') ?? 0)
@@ -211,10 +252,24 @@ export class DataService extends WorkerEntrypoint<Env> {
211
252
  }
212
253
  const response = await upstreamFetch(request, this.env)
213
254
  if (pathname.endsWith('/snapshot')) {
214
- if (heldSnapshots.has(namespace)) {
255
+ const limit = Number(url.searchParams.get('limit'))
256
+ if (Number.isSafeInteger(limit)) {
257
+ const limits = snapshotLimits.get(namespace) ?? []
258
+ limits.push(limit)
259
+ snapshotLimits.set(namespace, limits)
260
+ }
261
+ const shouldHold =
262
+ heldSnapshots.has(namespace) &&
263
+ (!holdSnapshotsAfterCursor.has(namespace) || url.searchParams.has('cursor'))
264
+ if (shouldHold) {
215
265
  activeSnapshots.add(namespace)
216
266
  try {
217
- while (heldSnapshots.has(namespace)) await scheduler.wait(10)
267
+ while (
268
+ heldSnapshots.has(namespace) &&
269
+ (!holdSnapshotsAfterCursor.has(namespace) || url.searchParams.has('cursor'))
270
+ ) {
271
+ await scheduler.wait(10)
272
+ }
218
273
  } finally {
219
274
  activeSnapshots.delete(namespace)
220
275
  }
@@ -227,6 +282,7 @@ export class DataService extends WorkerEntrypoint<Env> {
227
282
 
228
283
  export class AppService extends WorkerEntrypoint<Env> {
229
284
  fetch(request: Request): Promise<Response> {
285
+ delegatedUrl = request.url
230
286
  if (!new URL(request.url).pathname.endsWith('/api/zero/push')) {
231
287
  return Promise.resolve(
232
288
  new Response('APP route rejected non-push request', { status: 418 })
@@ -272,6 +328,8 @@ export default {
272
328
  Response.json({
273
329
  active: activeSnapshots.has(namespace),
274
330
  held: heldSnapshots.has(namespace),
331
+ afterCursor: holdSnapshotsAfterCursor.has(namespace),
332
+ limits: snapshotLimits.get(namespace) ?? [],
275
333
  })
276
334
  )
277
335
  }
@@ -281,12 +339,22 @@ export default {
281
339
  .then((body) => {
282
340
  if ((body as { hold?: unknown }).hold === true) {
283
341
  heldSnapshots.add(namespace)
342
+ if ((body as { afterCursor?: unknown }).afterCursor === true) {
343
+ holdSnapshotsAfterCursor.add(namespace)
344
+ } else {
345
+ holdSnapshotsAfterCursor.delete(namespace)
346
+ }
284
347
  } else {
285
348
  heldSnapshots.delete(namespace)
349
+ holdSnapshotsAfterCursor.delete(namespace)
286
350
  }
351
+ if ((body as { reset?: unknown }).reset === true)
352
+ snapshotLimits.set(namespace, [])
287
353
  return Response.json({
288
354
  active: activeSnapshots.has(namespace),
289
355
  held: heldSnapshots.has(namespace),
356
+ afterCursor: holdSnapshotsAfterCursor.has(namespace),
357
+ limits: snapshotLimits.get(namespace) ?? [],
290
358
  })
291
359
  })
292
360
  }
@@ -337,6 +405,7 @@ export default {
337
405
  delegatedFailuresRemaining,
338
406
  delegatedPushFailedRemaining,
339
407
  delegatedAttempts,
408
+ delegatedUrl,
340
409
  })
341
410
  )
342
411
  }
@@ -353,10 +422,12 @@ export default {
353
422
  Number((body as { pushFailed?: unknown }).pushFailed) || 0
354
423
  )
355
424
  delegatedAttempts = 0
425
+ delegatedUrl = ''
356
426
  return Response.json({
357
427
  delegatedFailuresRemaining,
358
428
  delegatedPushFailedRemaining,
359
429
  delegatedAttempts,
430
+ delegatedUrl,
360
431
  })
361
432
  })
362
433
  }
package/src/types.ts CHANGED
@@ -141,8 +141,10 @@ export type SyncHostConfig<Env extends SyncHostEnv = SyncHostEnv> = {
141
141
  hostVersion: string
142
142
  schema: ZeroSchemaConfig
143
143
  mutators?: MutatorRegistry
144
- /** Relative app push endpoint on the upstream service binding. */
144
+ /** Absolute app push path on the upstream service binding. */
145
145
  mutateUrl?: string
146
+ /** Absolute origin used for delegated push requests through the service binding. */
147
+ mutateOrigin?: string
146
148
  /** Env binding for delegated pushes; defaults to upstream.binding. */
147
149
  mutateBinding?: string
148
150
  delegatedPushRetry?: DelegatedPushRetryConfig
@@ -154,6 +156,12 @@ export type SyncHostConfig<Env extends SyncHostEnv = SyncHostEnv> = {
154
156
  request: Request,
155
157
  env: Env
156
158
  ): NormalizedClaims | null | Promise<NormalizedClaims | null>
159
+ /** Authorize the advisory wake socket before selecting a namespace DO.
160
+ * Browser clients should present a short-lived, namespace-scoped capability
161
+ * in the query string because WebSocket cannot set request headers. */
162
+ authorizeWake(request: Request, env: Env): boolean | Promise<boolean>
163
+ /** Authorize upstream service notifications before selecting a namespace DO. */
164
+ authorizeNotify(request: Request, env: Env): boolean | Promise<boolean>
157
165
  /** Resolve the first path component or another consumer-defined namespace. */
158
166
  namespace(request: Request): string | null
159
167
  visibility?: VisibilityConfig
@@ -187,22 +187,21 @@ export function trackBillableCursorRows<Cursor extends BillableCursor>(
187
187
  accountedRows = current
188
188
  record(delta)
189
189
  }
190
- const wrapIterator = (iterator: object): object =>
191
- new Proxy(iterator, {
192
- get(target, property) {
193
- const value = Reflect.get(target, property, target)
194
- if (property === 'next' && typeof value === 'function') {
195
- return (...args: unknown[]) => {
196
- try {
197
- return Reflect.apply(value, target, args)
198
- } finally {
199
- account()
200
- }
201
- }
190
+ const wrapIterator = (iterator: Iterator<unknown>): IterableIterator<unknown> => {
191
+ const next = iterator.next.bind(iterator)
192
+ return {
193
+ next(...args: [] | [unknown]) {
194
+ try {
195
+ return next(...args)
196
+ } finally {
197
+ account()
202
198
  }
203
- return typeof value === 'function' ? value.bind(target) : value
204
199
  },
205
- })
200
+ [Symbol.iterator]() {
201
+ return this
202
+ },
203
+ }
204
+ }
206
205
  let proxy: Cursor
207
206
  proxy = new Proxy(cursor, {
208
207
  get(target, property) {
@@ -222,7 +221,7 @@ export function trackBillableCursorRows<Cursor extends BillableCursor>(
222
221
  }
223
222
  if (property === 'raw' && typeof value === 'function') {
224
223
  return (...args: unknown[]) =>
225
- wrapIterator(Reflect.apply(value, target, args) as object)
224
+ wrapIterator(Reflect.apply(value, target, args) as Iterator<unknown>)
226
225
  }
227
226
  return typeof value === 'function' ? value.bind(target) : value
228
227
  },