@lunora/do 1.0.0-alpha.16 → 1.0.0-alpha.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.
package/dist/index.d.mts CHANGED
@@ -5846,8 +5846,12 @@ declare abstract class ShardDO {
5846
5846
  * subscriber counts are folded live from each socket's attachment via
5847
5847
  * {@link summarizeFanoutTopics}; the running per-path cost counters are the
5848
5848
  * in-memory {@link ShardDO.fanout} tallies, sharing `metrics.sinceMs` as the
5849
- * "since this instance woke" epoch. Read-only: touches no SQLite and mutates
5850
- * no socket state.
5849
+ * "since this instance woke" epoch. Touches no SQLite and mutates no socket
5850
+ * state; it does call `relay.relayCount()`, which advances the promotion
5851
+ * latch — safe here because that transition is a pure, monotonic function of
5852
+ * the live socket count (DOs are single-threaded), so a metrics poll only ever
5853
+ * drives the latch to the same state the routing path would compute for the
5854
+ * same count, never a divergent one.
5851
5855
  */
5852
5856
  private collectFanoutMetrics;
5853
5857
  /** Resolve a `getAuditLog` admin read, parsing the optional `limit`/`sinceSeq` cursor args and ensuring the reserved table first. */
@@ -6593,8 +6597,11 @@ interface WhereSqlStrategy {
6593
6597
  fieldRef: FieldRefSql;
6594
6598
  /**
6595
6599
  * Dialect `contains` rendering given the field reference and the (already
6596
- * bound) search term. Absent ⇒ the portable `… LIKE '%' || term || '%'`
6597
- * concat form (SQLite/Postgres); MySQL supplies a `CONCAT(...)` variant.
6600
+ * bound, already wildcard-escaped) search term. Absent ⇒ the portable
6601
+ * `… LIKE '%' || term || '%' ESCAPE '\'` concat form (SQLite/Postgres); MySQL
6602
+ * supplies a `CONCAT(...)` variant. The term is escaped by
6603
+ * {@link compileContains}, so an implementation MUST pair it with
6604
+ * `ESCAPE '\'` for the literal-match to hold.
6598
6605
  */
6599
6606
  likeContains?: (reference: SQL, term: SQL) => SQL;
6600
6607
  /**
package/dist/index.d.ts CHANGED
@@ -5846,8 +5846,12 @@ declare abstract class ShardDO {
5846
5846
  * subscriber counts are folded live from each socket's attachment via
5847
5847
  * {@link summarizeFanoutTopics}; the running per-path cost counters are the
5848
5848
  * in-memory {@link ShardDO.fanout} tallies, sharing `metrics.sinceMs` as the
5849
- * "since this instance woke" epoch. Read-only: touches no SQLite and mutates
5850
- * no socket state.
5849
+ * "since this instance woke" epoch. Touches no SQLite and mutates no socket
5850
+ * state; it does call `relay.relayCount()`, which advances the promotion
5851
+ * latch — safe here because that transition is a pure, monotonic function of
5852
+ * the live socket count (DOs are single-threaded), so a metrics poll only ever
5853
+ * drives the latch to the same state the routing path would compute for the
5854
+ * same count, never a divergent one.
5851
5855
  */
5852
5856
  private collectFanoutMetrics;
5853
5857
  /** Resolve a `getAuditLog` admin read, parsing the optional `limit`/`sinceSeq` cursor args and ensuring the reserved table first. */
@@ -6593,8 +6597,11 @@ interface WhereSqlStrategy {
6593
6597
  fieldRef: FieldRefSql;
6594
6598
  /**
6595
6599
  * Dialect `contains` rendering given the field reference and the (already
6596
- * bound) search term. Absent ⇒ the portable `… LIKE '%' || term || '%'`
6597
- * concat form (SQLite/Postgres); MySQL supplies a `CONCAT(...)` variant.
6600
+ * bound, already wildcard-escaped) search term. Absent ⇒ the portable
6601
+ * `… LIKE '%' || term || '%' ESCAPE '\'` concat form (SQLite/Postgres); MySQL
6602
+ * supplies a `CONCAT(...)` variant. The term is escaped by
6603
+ * {@link compileContains}, so an implementation MUST pair it with
6604
+ * `ESCAPE '\'` for the literal-match to hold.
6598
6605
  */
6599
6606
  likeContains?: (reference: SQL, term: SQL) => SQL;
6600
6607
  /**
package/dist/index.mjs CHANGED
@@ -3,15 +3,15 @@ export { AGGREGATE_SQL_FUNCTION, aggregateSqlFunction, matchesStaticWhere, norma
3
3
  export { aggregateTableName, coerceAggregateNumber, encodeAggregateKey, foldAggregateTally, readAggregateValue } from './packem_shared/aggregateTableName-CxNqY1Sl.mjs';
4
4
  export { CountRlsUnsupportedError, mergeWhere, planAggregateLookup, selectIndexForAggregate, selectIndexForCount, selectIndexForGroupBy } from './packem_shared/CountRlsUnsupportedError-28ZvvwKS.mjs';
5
5
  export { AUTH_METRICS_BUCKETS_TABLE, AUTH_METRICS_BUCKET_MS, AUTH_METRICS_BUCKET_RETENTION, AUTH_METRICS_TABLE, ensureAuthMetricsTables, readAuthMetrics, recordAuthEvent } from './packem_shared/AUTH_METRICS_BUCKETS_TABLE-CiHHYeJi.mjs';
6
- export { NotUniqueError, assertValidClientId, createShardCtxDb, normalizeIdStructurally } from './packem_shared/NotUniqueError-BXn2j5vu.mjs';
6
+ export { NotUniqueError, assertValidClientId, createShardCtxDb, normalizeIdStructurally } from './packem_shared/NotUniqueError-Do5h_jiW.mjs';
7
7
  export { DATA_MIGRATION_STATE_TABLE, readMigrationStatus, runDataMigration } from './packem_shared/DATA_MIGRATION_STATE_TABLE-DfPxn8I0.mjs';
8
8
  export { SCAN_DEP, createDependencyTracker, depKey } from './packem_shared/SCAN_DEP-DLJF8dsj.mjs';
9
9
  export { renderSql } from './packem_shared/renderSql-D6eUcn2N.mjs';
10
10
  export { diffExternalSource } from './packem_shared/diffExternalSource-Cx9HUPJj.mjs';
11
- export { materializeExternalRows, readExternalSourceBaseline, runExternalSourceTick } from './packem_shared/materializeExternalRows-uKBx9H0s.mjs';
12
- export { isSourceDue, liftSourceId, pullExternalSourceTick } from './packem_shared/isSourceDue-BMTJv65e.mjs';
11
+ export { materializeExternalRows, readExternalSourceBaseline, runExternalSourceTick } from './packem_shared/materializeExternalRows-Bj6EXy50.mjs';
12
+ export { isSourceDue, liftSourceId, pullExternalSourceTick } from './packem_shared/isSourceDue-9mJRJ9Ld.mjs';
13
13
  export { FUNCTION_METRICS_BUCKETS_TABLE, FUNCTION_METRICS_BUCKET_MS, FUNCTION_METRICS_BUCKET_RETENTION, FUNCTION_METRICS_INDEX_TABLE, FUNCTION_METRICS_TABLE, ensureFunctionMetricsTables, readFunctionMetricBuckets, readFunctionMetricIndexHits, readFunctionMetrics, readFunctionMetricsTotals, recordFunctionMetric } from './packem_shared/FUNCTION_METRICS_BUCKETS_TABLE-UDNVD7FS.mjs';
14
- export { ADMIN_FUNCTIONS, ADMIN_FUNCTION_PREFIX, FLAGS_FUNCTION_PREFIX, RELATION_FUNCTION_PREFIX, facetColumn, listTables, readTablePage, selectMatchingIds } from './packem_shared/ADMIN_FUNCTIONS-fMl1vyt1.mjs';
14
+ export { ADMIN_FUNCTIONS, ADMIN_FUNCTION_PREFIX, FLAGS_FUNCTION_PREFIX, RELATION_FUNCTION_PREFIX, facetColumn, listTables, readTablePage, selectMatchingIds } from './packem_shared/ADMIN_FUNCTIONS-BvGiv2u6.mjs';
15
15
  export { LogBuffer } from './packem_shared/LogBuffer-B_Ezju_N.mjs';
16
16
  export { MAIL_RETENTION, MAIL_TABLE, clearCapturedMail, ensureMailTable, readCapturedMail, recordCapturedMail } from './packem_shared/MAIL_RETENTION-CPpgl-dX.mjs';
17
17
  export { default as NotFoundError } from './packem_shared/NotFoundError-CMuMZt81.mjs';
@@ -19,20 +19,20 @@ export { armRestore, readBookmark } from './packem_shared/armRestore-BJk53Ro8.mj
19
19
  export { applySelect, buildSeekWhere, decodeCursor, encodeCursor, normalizeOrderKeys, softDeleteScope } from './packem_shared/applySelect-BvZdFUBT.mjs';
20
20
  export { RANK_TIEBREAK, encodePartitionKey, matchesRankStaticWhere, rankTableName, resolveRankPartition, sortColumnName } from './packem_shared/RANK_TIEBREAK-CXhdcA1o.mjs';
21
21
  export { ReactiveCache, reactiveCacheKey } from './packem_shared/ReactiveCache-BYlSGY0N.mjs';
22
- export { serveRelationFanout } from './packem_shared/serveRelationFanout-EZLD9-O3.mjs';
22
+ export { serveRelationFanout } from './packem_shared/serveRelationFanout-BuSmWawx.mjs';
23
23
  export { DEFAULT_MAX_RELATION_KEYS, assertFlatPredicate, assertShapeShardable, containsRelationPredicate, isRelationPredicate, resolveRelationPredicates } from './packem_shared/DEFAULT_MAX_RELATION_KEYS-DU-Y4-LJ.mjs';
24
24
  export { applyOnDelete, fanOutScalarCounts, resolveWith, runRowValidators } from './packem_shared/applyOnDelete-BQ-8ZlZ1.mjs';
25
25
  export { RLS_UNWRAP_SYMBOL, RlsRequiredError, guardWriter } from './packem_shared/RLS_UNWRAP_SYMBOL-EtGQdC9d.mjs';
26
26
  export { buildFtsMatch, ftsTableName, scoreDocument, stringifySearchText, tokenizeSearch } from './packem_shared/buildFtsMatch-BLEMawrp.mjs';
27
27
  export { M as MIN_ADMIN_TOKEN_LENGTH, a as MIN_AUTH_SECRET_LENGTH, b as buildSecurityAudit } from './packem_shared/security-audit-CucgBice.mjs';
28
28
  export { SESSION_DO_TTL_DEFAULT, SessionDO } from './packem_shared/SESSION_DO_TTL_DEFAULT-ilPZsVwu.mjs';
29
- export { ROOT_DO_SIZE_WARN_BYTES, ROOT_SHARD_NAME, ShardDO } from './packem_shared/ROOT_DO_SIZE_WARN_BYTES-CtTkRXH0.mjs';
29
+ export { ROOT_DO_SIZE_WARN_BYTES, ROOT_SHARD_NAME, ShardDO } from './packem_shared/ROOT_DO_SIZE_WARN_BYTES-DHNOI0RZ.mjs';
30
30
  export { SHARD_REGISTRY_DO_NAME, ShardRegistryDO } from './packem_shared/SHARD_REGISTRY_DO_NAME-BsAbi5Mn.mjs';
31
31
  export { MAX_SQL_ROWS, assertReadonly, runReadonlySql } from './packem_shared/MAX_SQL_ROWS-dDcFE1YZ.mjs';
32
32
  export { createSystemReader } from './packem_shared/createSystemReader-8CzSZP9V.mjs';
33
33
  export { ConflictError } from './packem_shared/ConflictError-C0STs6bU.mjs';
34
34
  export { hasTrigger, runTriggers } from './packem_shared/hasTrigger-5N6_Fx0A.mjs';
35
- export { compileWhereSql } from './packem_shared/compileWhereSql-CXrhFA3G.mjs';
35
+ export { compileWhereSql } from './packem_shared/compileWhereSql-MW_Lk8nJ.mjs';
36
36
  export { CDC_LOG_TABLE, applyCdcChanges, readCdcChanges, trimCdcChanges } from './packem_shared/CDC_LOG_TABLE-DSycmnDf.mjs';
37
37
  export { backfillAggregateIndexes, backfillRankIndexes } from './packem_shared/backfillAggregateIndexes-BZsOqDXP.mjs';
38
38
  export { runShardMigrations } from './packem_shared/runShardMigrations-nIwoQeOK.mjs';
@@ -1,3 +1,5 @@
1
+ const quoteIdentifier = (name) => `"${name.replaceAll('"', '""')}"`;
2
+
1
3
  const ADMIN_FUNCTION_PREFIX = "__lunora_admin__:";
2
4
  const RELATION_FUNCTION_PREFIX = "__lunora_relation__:";
3
5
  const FLAGS_FUNCTION_PREFIX = "__lunora_flags__:";
@@ -95,7 +97,6 @@ const expandDocumentRows = (columns, rows) => {
95
97
  };
96
98
  const escapeLike = (value) => value.replaceAll(/[\\%_]/g, (character) => `\\${character}`);
97
99
  const isInternalTable = (name) => name.startsWith("sqlite_") || name.startsWith("_cf_") || name.startsWith("__miniflare") || name.startsWith("__lunora") || name.includes("__fts_");
98
- const quoteIdentifier = (name) => `"${name.replaceAll('"', '""')}"`;
99
100
  const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
100
101
  const countRows = (sql, quotedTable) => {
101
102
  const row = sql.exec(`SELECT COUNT(*) AS c FROM ${quotedTable}`).one();
@@ -19,11 +19,11 @@ import { guardWriter } from './RLS_UNWRAP_SYMBOL-EtGQdC9d.mjs';
19
19
  import { createSystemReader } from './createSystemReader-8CzSZP9V.mjs';
20
20
  import { ConflictError } from './ConflictError-C0STs6bU.mjs';
21
21
  import { runTriggers } from './hasTrigger-5N6_Fx0A.mjs';
22
- import { compileWhereSql } from './compileWhereSql-CXrhFA3G.mjs';
22
+ import { compileWhereSql } from './compileWhereSql-MW_Lk8nJ.mjs';
23
23
  export { backfillAggregateIndexes, backfillRankIndexes } from './backfillAggregateIndexes-BZsOqDXP.mjs';
24
24
  export { C as CLIENT_WATERMARK_TABLE, G as GLOBAL_SHAPE_SNAPSHOT_TABLE, I as IDEMPOTENCY_TABLE, c as advanceClientWatermark, d as deleteGlobalShapeSnapshot, e as deleteGlobalShapeSnapshotsForConnection, m as migrateClientWatermark, b as migrateGlobalShapeSnapshot, r as readClientWatermark, f as readGlobalShapeSnapshot, g as readIdempotent, t as trimIdempotent, w as writeGlobalShapeSnapshot, h as writeIdempotent } from './ctx-db-idempotency-BdcNpvY4.mjs';
25
25
  export { runShardMigrations } from './runShardMigrations-nIwoQeOK.mjs';
26
- export { a as selectShapeMemberIds, s as selectShapeRows } from './ctx-db-shapes-BQapRFjB.mjs';
26
+ export { a as selectShapeMemberIds, s as selectShapeRows } from './ctx-db-shapes-0RaIOy7J.mjs';
27
27
 
28
28
  const rankIndexFieldsUnchanged = (index, previous, next) => {
29
29
  const fields = [...index.partitionBy ?? [], ...index.sortBy.map((key) => key.field), ...index.where ? Object.keys(index.where) : []];
@@ -5,7 +5,7 @@ import { recordAuthEvent, readAuthMetrics } from './AUTH_METRICS_BUCKETS_TABLE-C
5
5
  import { DATA_MIGRATION_STATE_TABLE, readMigrationStatus } from './DATA_MIGRATION_STATE_TABLE-DfPxn8I0.mjs';
6
6
  import { SCAN_DEP, createDependencyTracker, tableFromDepKey } from './SCAN_DEP-DLJF8dsj.mjs';
7
7
  import { readFunctionMetricsTotals, readFunctionMetricIndexHits, recordFunctionMetric, mergeScanAttribution, readFunctionMetrics, readFunctionMetricBuckets } from './FUNCTION_METRICS_BUCKETS_TABLE-UDNVD7FS.mjs';
8
- import { createFanoutCounters, ADMIN_FUNCTION_PREFIX, RELATION_FUNCTION_PREFIX, selectMatchingIds, ADMIN_FUNCTIONS, findStorageReferences, listTables, summarizeSubscriptions, summarizeFanoutTopics, readTablePage, facetColumn, FLAGS_FUNCTION_PREFIX, recordFanoutPass, MAX_PAGE_SIZE } from './ADMIN_FUNCTIONS-fMl1vyt1.mjs';
8
+ import { createFanoutCounters, ADMIN_FUNCTION_PREFIX, RELATION_FUNCTION_PREFIX, selectMatchingIds, ADMIN_FUNCTIONS, findStorageReferences, listTables, summarizeSubscriptions, summarizeFanoutTopics, readTablePage, facetColumn, FLAGS_FUNCTION_PREFIX, recordFanoutPass, MAX_PAGE_SIZE } from './ADMIN_FUNCTIONS-BvGiv2u6.mjs';
9
9
  import { LogBuffer } from './LogBuffer-B_Ezju_N.mjs';
10
10
  import { recordCapturedMail, clearCapturedMail, readCapturedMail, MAIL_TABLE } from './MAIL_RETENTION-CPpgl-dX.mjs';
11
11
  import { readBookmark, armRestore } from './armRestore-BJk53Ro8.mjs';
@@ -17,7 +17,7 @@ import { runReadonlySql } from './MAX_SQL_ROWS-dDcFE1YZ.mjs';
17
17
  import { ConflictError } from './ConflictError-C0STs6bU.mjs';
18
18
  import { e as deleteGlobalShapeSnapshotsForConnection, g as readIdempotent, h as writeIdempotent, t as trimIdempotent, r as readClientWatermark, m as migrateClientWatermark, c as advanceClientWatermark, d as deleteGlobalShapeSnapshot, f as readGlobalShapeSnapshot, w as writeGlobalShapeSnapshot } from './ctx-db-idempotency-BdcNpvY4.mjs';
19
19
  import { CDC_LOG_TABLE, readCdcChanges, readCdcCursor, readCdcEpoch, minCdcSeq, bumpCdcEpoch } from './CDC_LOG_TABLE-DSycmnDf.mjs';
20
- import { a as selectShapeMemberIds, s as selectShapeRows } from './ctx-db-shapes-BQapRFjB.mjs';
20
+ import { a as selectShapeMemberIds, s as selectShapeRows } from './ctx-db-shapes-0RaIOy7J.mjs';
21
21
 
22
22
  const MAX_BATCH_ENTRIES = 500;
23
23
 
@@ -196,7 +196,22 @@ const parseRelayName = (name) => {
196
196
  };
197
197
 
198
198
  const shapeRoutingKey = (name, args) => stableStringify({ args: args ?? {}, name });
199
- const DEFAULT_PROMOTION_THRESHOLDS = { tUp: 8e3 };
199
+ const DEFAULT_PROMOTION_THRESHOLDS = { tDown: 4e3, tUp: 8e3 };
200
+ const nextPromotionState = (current, subscribers, thresholds = DEFAULT_PROMOTION_THRESHOLDS) => {
201
+ if (thresholds.tDown >= thresholds.tUp) {
202
+ throw new Error(`invalid promotion thresholds: tDown (${String(thresholds.tDown)}) must be < tUp (${String(thresholds.tUp)})`);
203
+ }
204
+ if (current === "owned") {
205
+ return subscribers >= thresholds.tUp ? "promoted" : "owned";
206
+ }
207
+ return subscribers < thresholds.tDown ? "owned" : "promoted";
208
+ };
209
+ const clampPromotionThresholds = (tUp, tDownRaw) => {
210
+ if (tDownRaw < tUp) {
211
+ return { tDown: tDownRaw, tUp };
212
+ }
213
+ return { tDown: Math.min(Math.max(1, Math.floor(tUp / 2)), tUp - 1), tUp };
214
+ };
200
215
 
201
216
  const projectColumns = (document_, columns) => {
202
217
  if (!columns) {
@@ -232,11 +247,12 @@ const diffGlobalMembership = (rows, previous, options) => {
232
247
  }
233
248
  return { next, rowsPatch };
234
249
  };
235
- const buildPokeFrames = (parts, meta) => {
250
+ const encodeRowsPatch = (rowsPatch) => rowsPatch.map((op) => op.value === void 0 ? op : { ...op, value: encodeWire(op.value) });
251
+ const buildPokeFrames = (parts, meta, options = {}) => {
236
252
  const { baseCheckpoint, checkpoint, epoch, lastMutationId, pokeId } = meta;
237
253
  const frames = [JSON.stringify({ baseCheckpoint, epoch, pokeId, type: "pokeStart" })];
238
254
  for (const part of parts) {
239
- const rowsPatch = part.rowsPatch.map((op) => op.value === void 0 ? op : { ...op, value: encodeWire(op.value) });
255
+ const rowsPatch = options.preEncoded ? part.rowsPatch : encodeRowsPatch(part.rowsPatch);
240
256
  frames.push(
241
257
  JSON.stringify({
242
258
  pokeId,
@@ -253,6 +269,30 @@ const buildPokeFrames = (parts, meta) => {
253
269
 
254
270
  const DEFAULT_RELAY_FAN = 2;
255
271
  const DEFAULT_MAX_RELAYS = 8;
272
+ const RELAY_SECRET_KEY = "LUNORA_RELAY_SECRET";
273
+ const RELAY_SIGNATURE_HEADER = "x-lunora-relay-sig";
274
+ const relaySecretOf = (env) => {
275
+ const value = env?.[RELAY_SECRET_KEY];
276
+ return typeof value === "string" && value.length > 0 ? value : void 0;
277
+ };
278
+ const constantTimeEqual$1 = (a, b) => {
279
+ if (a.length !== b.length) {
280
+ return false;
281
+ }
282
+ let mismatch = 0;
283
+ for (let index = 0; index < a.length; index += 1) {
284
+ const charA = a.charCodeAt(index);
285
+ const charB = b.charCodeAt(index);
286
+ mismatch |= charA ^ charB;
287
+ }
288
+ return mismatch === 0;
289
+ };
290
+ const signRelayBody = async (secret, body) => {
291
+ const encoder = new TextEncoder();
292
+ const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { hash: "SHA-256", name: "HMAC" }, false, ["sign"]);
293
+ const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(body));
294
+ return [...new Uint8Array(signature)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
295
+ };
256
296
  const envPositiveInt = (env, key, fallback) => {
257
297
  const raw = env?.[key];
258
298
  let parsed = Number.NaN;
@@ -289,9 +329,23 @@ class RelayLink {
289
329
  * to `relay.ts` without a case here is a COMPILE error, not a runtime mis-route.
290
330
  */
291
331
  async handleControl(request) {
332
+ let raw;
333
+ try {
334
+ raw = await request.text();
335
+ } catch {
336
+ return new Response("bad request", { status: 400 });
337
+ }
338
+ const secret = relaySecretOf(this.host.env());
339
+ if (secret !== void 0) {
340
+ const supplied = request.headers.get(RELAY_SIGNATURE_HEADER);
341
+ const expected = await signRelayBody(secret, raw);
342
+ if (supplied === null || !constantTimeEqual$1(supplied, expected)) {
343
+ return new Response("forbidden", { status: 403 });
344
+ }
345
+ }
292
346
  let message;
293
347
  try {
294
- message = await request.json();
348
+ message = JSON.parse(raw);
295
349
  } catch {
296
350
  return new Response("bad request", { status: 400 });
297
351
  }
@@ -356,10 +410,16 @@ class RelayLink {
356
410
  return void 0;
357
411
  }
358
412
  const stub = typeof namespace.getByName === "function" ? namespace.getByName(targetName) : namespace.get(namespace.idFromName(targetName));
413
+ const body = JSON.stringify(message);
414
+ const headers = { "content-type": "application/json", "x-lunora-shard-binding": this.host.shardBinding() ?? "" };
415
+ const secret = relaySecretOf(this.host.env());
416
+ if (secret !== void 0) {
417
+ headers[RELAY_SIGNATURE_HEADER] = await signRelayBody(secret, body);
418
+ }
359
419
  try {
360
420
  return await stub.fetch("https://relay.internal/_lunora/relay", {
361
- body: JSON.stringify(message),
362
- headers: { "content-type": "application/json", "x-lunora-shard-binding": this.host.shardBinding() ?? "" },
421
+ body,
422
+ headers,
363
423
  method: "POST"
364
424
  });
365
425
  } catch {
@@ -376,6 +436,8 @@ class OwnerRelay extends RelayLink {
376
436
  relayShapeRegistry = /* @__PURE__ */ new Map();
377
437
  /** NON-uniform (identity-scoped) relay shapes, one entry per relay socket, keyed `relayIndex:connectionId:subId`. Each is served live by a per-socket proxy poke. */
378
438
  relayShapeProxies = /* @__PURE__ */ new Map();
439
+ /** The owner's current promotion state (plan 075 Phase 4), carried across `relayCount()` calls so hysteresis has memory — a shard hovering near the threshold can't flap. */
440
+ promotionState = "owned";
379
441
  constructor(host, ownerKey) {
380
442
  super(host, { ownerKey });
381
443
  }
@@ -406,13 +468,22 @@ class OwnerRelay extends RelayLink {
406
468
  }
407
469
  /**
408
470
  * How many relays the runtime should spread new connections across for this shard
409
- * (plan 075 Phase 2). `0` keeps every connection on the owner. The owner promotes
410
- * once its live socket count crosses `LUNORA_RELAY_THRESHOLD`, fanning to a fixed
411
- * `LUNORA_RELAY_FAN` (capped by `LUNORA_MAX_RELAYS`, the cost ceiling).
471
+ * (plan 075 Phase 2, hysteresis added in Phase 4). `0` keeps every connection on
472
+ * the owner. The owner promotes once its live socket count reaches
473
+ * `LUNORA_RELAY_THRESHOLD` (`tUp`), fanning to a fixed `LUNORA_RELAY_FAN` (capped
474
+ * by `LUNORA_MAX_RELAYS`, the cost ceiling), and only collapses back to
475
+ * owner-served once subscribers drain below `LUNORA_RELAY_COLLAPSE_THRESHOLD`
476
+ * (`tDown`) — the band between the two holds the current state so a shard
477
+ * hovering near the threshold can't flap. {@link clampPromotionThresholds}
478
+ * guarantees a valid `tDown < tUp` band even under a misconfigured collapse
479
+ * threshold. Advances the promotion latch, so it is not a pure read.
412
480
  */
413
481
  relayCount() {
414
- const threshold = envPositiveInt(this.host.env(), "LUNORA_RELAY_THRESHOLD", DEFAULT_PROMOTION_THRESHOLDS.tUp);
415
- if (this.host.getWebSockets().length < threshold) {
482
+ const subscribers = this.host.getWebSockets().length;
483
+ const tUp = envPositiveInt(this.host.env(), "LUNORA_RELAY_THRESHOLD", DEFAULT_PROMOTION_THRESHOLDS.tUp);
484
+ const tDownRaw = envPositiveInt(this.host.env(), "LUNORA_RELAY_COLLAPSE_THRESHOLD", DEFAULT_PROMOTION_THRESHOLDS.tDown);
485
+ this.promotionState = nextPromotionState(this.promotionState, subscribers, clampPromotionThresholds(tUp, tDownRaw));
486
+ if (this.promotionState === "owned") {
416
487
  return 0;
417
488
  }
418
489
  const maxRelays = envPositiveInt(this.host.env(), "LUNORA_MAX_RELAYS", DEFAULT_MAX_RELAYS);
@@ -497,7 +568,10 @@ class OwnerRelay extends RelayLink {
497
568
  epoch,
498
569
  fromCursor,
499
570
  name: entry.name,
500
- rowsPatch,
571
+ // Wire-encode before the poke crosses the owner→relay `JSON.stringify`
572
+ // hop (`requestRelayMessage`); the relay re-frames it with
573
+ // `preEncoded` so a `bytes`/`bigint` shape column isn't dropped/truncated.
574
+ rowsPatch: encodeRowsPatch(rowsPatch),
501
575
  type: "relay_shape_poke"
502
576
  };
503
577
  for (const index of relays) {
@@ -541,7 +615,9 @@ class OwnerRelay extends RelayLink {
541
615
  epoch,
542
616
  fromCursor,
543
617
  name: entry.name,
544
- rowsPatch,
618
+ // Wire-encode before the owner→relay `JSON.stringify` hop (see the
619
+ // cohort-multicast path); the relay re-frames it with `preEncoded`.
620
+ rowsPatch: encodeRowsPatch(rowsPatch),
545
621
  targetConnectionId: entry.connectionId,
546
622
  type: "relay_shape_poke"
547
623
  };
@@ -647,8 +723,9 @@ class OwnerRelay extends RelayLink {
647
723
  * sound against the cross-identity row-leak (review). Resolves under the anonymous
648
724
  * multicast identity (the base) plus two `Proxy`-backed identities that return a
649
725
  * distinct value for ANY accessed claim, requires all to agree on table + where +
650
- * columns, rejects any table with an RLS read policy or a masked projected column,
651
- * and fails closed if the claims are enumerated (a wholesale copy the proxy can't
726
+ * columns, rejects any table with an RLS read policy or ANY masked column
727
+ * defined (even if unprojected see {@link OwnerRelay.tableHasAnyMask}), and
728
+ * fails closed if the claims are enumerated (a wholesale copy the proxy can't
652
729
  * differentiate).
653
730
  */
654
731
  probeShapeRelayUniform(name, args) {
@@ -664,7 +741,7 @@ class OwnerRelay extends RelayLink {
664
741
  if (this.host.rlsMetadata().policies.some((policy) => policy.on === "read" && policy.table === base.table)) {
665
742
  return false;
666
743
  }
667
- if (this.shapeColumnsMasked(base.table, base.columns)) {
744
+ if (this.tableHasAnyMask(base.table)) {
668
745
  return false;
669
746
  }
670
747
  const baseWhere = stableStringify(base.effectiveWhere);
@@ -702,17 +779,18 @@ class OwnerRelay extends RelayLink {
702
779
  });
703
780
  return matches && !enumerated;
704
781
  }
705
- /** Whether any column the shape projects from `table` is masked — a masked value is identity-dependent, so the shape can't be relay-uniform. */
706
- shapeColumnsMasked(table, columns) {
707
- const masked = this.host.maskMetadata().columns.filter((entry) => entry.table === table);
708
- if (masked.length === 0) {
709
- return false;
710
- }
711
- if (columns === void 0) {
712
- return true;
713
- }
714
- const projected = new Set(columns);
715
- return masked.some((entry) => projected.has(entry.column));
782
+ /**
783
+ * Whether `table` has ANY masked column defined (L7). Deliberately conservative:
784
+ * we disqualify a shape from relay-multicast if its table declares any mask at
785
+ * all, even when the current query doesn't project the masked column. A masked
786
+ * value is identity-dependent, and keying uniformity off the *projected* set
787
+ * meant a later column addition (or a `select` change) could silently widen a
788
+ * cohort to include an identity-dependent value. Refusing on any table-level
789
+ * mask removes that footgun; the cost is a few extra shapes falling back to the
790
+ * per-identity path, which is always correct.
791
+ */
792
+ tableHasAnyMask(table) {
793
+ return this.host.maskMetadata().columns.some((entry) => entry.table === table);
716
794
  }
717
795
  }
718
796
  class RelayMember extends RelayLink {
@@ -861,13 +939,19 @@ class RelayMember extends RelayLink {
861
939
  if (memo?.cursor !== poke.fromCursor || memo.epoch !== poke.epoch || shapeRoutingKey(sub.name, sub.args) !== routingKey) {
862
940
  continue;
863
941
  }
864
- const frames = buildPokeFrames([{ rowsPatch: poke.rowsPatch, shapeId: subId }], {
865
- baseCheckpoint: void 0,
866
- checkpoint: poke.checkpoint,
867
- epoch: poke.epoch,
868
- lastMutationId: void 0,
869
- pokeId: this.host.nextPokeId()
870
- });
942
+ const frames = buildPokeFrames(
943
+ [{ rowsPatch: poke.rowsPatch, shapeId: subId }],
944
+ {
945
+ baseCheckpoint: void 0,
946
+ checkpoint: poke.checkpoint,
947
+ epoch: poke.epoch,
948
+ lastMutationId: void 0,
949
+ pokeId: this.host.nextPokeId()
950
+ },
951
+ // `poke.rowsPatch` was wire-encoded by the owner before it crossed
952
+ // the hub — don't double-encode it here.
953
+ { preEncoded: true }
954
+ );
871
955
  for (const frame of frames) {
872
956
  trySendFrame(ws, frame);
873
957
  }
@@ -4651,8 +4735,12 @@ class ShardDO {
4651
4735
  * subscriber counts are folded live from each socket's attachment via
4652
4736
  * {@link summarizeFanoutTopics}; the running per-path cost counters are the
4653
4737
  * in-memory {@link ShardDO.fanout} tallies, sharing `metrics.sinceMs` as the
4654
- * "since this instance woke" epoch. Read-only: touches no SQLite and mutates
4655
- * no socket state.
4738
+ * "since this instance woke" epoch. Touches no SQLite and mutates no socket
4739
+ * state; it does call `relay.relayCount()`, which advances the promotion
4740
+ * latch — safe here because that transition is a pure, monotonic function of
4741
+ * the live socket count (DOs are single-threaded), so a metrics poll only ever
4742
+ * drives the latch to the same state the routing path would compute for the
4743
+ * same count, never a divergent one.
4656
4744
  */
4657
4745
  collectFanoutMetrics() {
4658
4746
  const summary = summarizeFanoutTopics(this.state.getWebSockets().map((ws) => this.readAttachment(ws)));
@@ -11,9 +11,10 @@ const isOperatorObject = (value) => {
11
11
  const keys = Object.keys(value);
12
12
  return keys.length > 0 && keys.every((key) => OPERATOR_KEY_SET.has(key));
13
13
  };
14
+ const escapeLikeTerm = (value) => typeof value === "string" ? value.replaceAll(/[\\%_]/g, (character) => `\\${character}`) : value;
14
15
  const compileContains = (reference, value, strategy) => {
15
- const term = sql`${strategy.serialize(value)}`;
16
- return strategy.likeContains ? strategy.likeContains(reference, term) : sql`${reference} LIKE '%' || ${term} || '%'`;
16
+ const term = sql`${strategy.serialize(escapeLikeTerm(value))}`;
17
+ return strategy.likeContains ? strategy.likeContains(reference, term) : sql`${reference} LIKE '%' || ${term} || '%' ESCAPE '\\'`;
17
18
  };
18
19
  const compileComparator = (reference, operator, comparator, value, strategy) => {
19
20
  if (value === null) {
@@ -1,7 +1,7 @@
1
1
  import { sql } from 'drizzle-orm';
2
2
  import { r as runDrizzle } from './do-exec-5eQy5cEi.mjs';
3
3
  import { D as DOC_COLUMN, r as rowToDocument, j as jsonPathSql } from './do-sql-BCHCWtrD.mjs';
4
- import { compileWhereSql } from './compileWhereSql-CXrhFA3G.mjs';
4
+ import { compileWhereSql } from './compileWhereSql-MW_Lk8nJ.mjs';
5
5
  import { s as serializeSqlValue } from './serialize-sql-BlRUoiQe.mjs';
6
6
 
7
7
  const shapeWhereStrategy = { fieldRef: jsonPathSql, serialize: serializeSqlValue };
@@ -1,4 +1,4 @@
1
- import { runExternalSourceTick } from './materializeExternalRows-uKBx9H0s.mjs';
1
+ import { runExternalSourceTick } from './materializeExternalRows-Bj6EXy50.mjs';
2
2
 
3
3
  const liftSourceId = (row, options = {}) => {
4
4
  const { idColumn = "id", map } = options;
@@ -1,5 +1,5 @@
1
1
  import { applyCdcChanges } from './CDC_LOG_TABLE-DSycmnDf.mjs';
2
- import { s as selectShapeRows } from './ctx-db-shapes-BQapRFjB.mjs';
2
+ import { s as selectShapeRows } from './ctx-db-shapes-0RaIOy7J.mjs';
3
3
  import { diffExternalSource, projectExternalSourceRow } from './diffExternalSource-Cx9HUPJj.mjs';
4
4
  import { stableStringify } from './stableStringify-MydiuScU.mjs';
5
5
 
@@ -1,4 +1,4 @@
1
- import { RELATION_FUNCTION_PREFIX } from './ADMIN_FUNCTIONS-fMl1vyt1.mjs';
1
+ import { RELATION_FUNCTION_PREFIX } from './ADMIN_FUNCTIONS-BvGiv2u6.mjs';
2
2
 
3
3
  const serveRelationFanout = async (schema, database, functionPath, args) => {
4
4
  const table = typeof args["table"] === "string" ? args["table"] : "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/do",
3
- "version": "1.0.0-alpha.16",
3
+ "version": "1.0.0-alpha.18",
4
4
  "description": "Lunora Durable Objects: ShardDO (SQLite, OCC, hibernated WebSocket subscriptions) and SessionDO",
5
5
  "keywords": [
6
6
  "cloudflare",