okengine 0.17.2 → 0.18.4
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/AGENTS.md +5 -3
- package/README.md +14 -10
- package/manifest.v1.schema.json +61 -2
- package/package.json +19 -5
- package/site/content/docs/ai/index.mdx +1 -1
- package/site/content/docs/ai/mcp.mdx +10 -1
- package/site/content/docs/ai/skills.mdx +9 -9
- package/site/content/docs/elements/ai.mdx +1 -1
- package/site/content/docs/elements/clock.mdx +1 -1
- package/site/content/docs/elements/flow.mdx +25 -1
- package/site/content/docs/elements/gate.mdx +3 -2
- package/site/content/docs/elements/store.mdx +289 -341
- package/site/content/docs/elements/vault.mdx +5 -5
- package/site/content/docs/get-started/basic-usage.mdx +3 -10
- package/site/content/docs/get-started/index.mdx +1 -1
- package/site/content/docs/get-started/installation.mdx +2 -3
- package/site/content/docs/get-started/introduction.mdx +58 -121
- package/site/content/docs/get-started/meta.json +9 -1
- package/site/content/docs/get-started/project-structure.mdx +4 -11
- package/site/content/docs/get-started/testing.mdx +328 -0
- package/site/content/docs/get-started/why.mdx +93 -71
- package/site/content/docs/index.mdx +44 -11
- package/site/content/docs/meta.json +8 -5
- package/site/content/docs/plugins/apple.mdx +151 -0
- package/site/content/docs/plugins/discord.mdx +139 -0
- package/site/content/docs/plugins/facebook.mdx +134 -0
- package/site/content/docs/plugins/figma.mdx +138 -0
- package/site/content/docs/plugins/github.mdx +138 -0
- package/site/content/docs/plugins/google.mdx +153 -0
- package/site/content/docs/plugins/index.mdx +47 -1
- package/site/content/docs/plugins/meta.json +10 -0
- package/site/content/docs/plugins/microsoft.mdx +151 -0
- package/site/content/docs/plugins/oauth.mdx +188 -0
- package/site/content/docs/plugins/x.mdx +125 -0
- package/site/content/docs/providers/index.mdx +2 -0
- package/site/content/docs/recipes/index.mdx +2 -0
- package/site/content/docs/reference/cli.md +3 -2
- package/site/content/docs/reference/client.mdx +58 -1
- package/site/content/docs/reference/configuration.mdx +2 -4
- package/site/content/docs/reference/fx.mdx +3 -1
- package/site/content/docs/reference/index.mdx +0 -5
- package/site/content/docs/reference/meta.json +2 -2
- package/site/content/docs/reference/okid.mdx +137 -0
- package/src/auth/bindings.ts +1 -1
- package/src/auth/config.ts +9 -0
- package/src/auth/identity-sql.ts +314 -0
- package/src/auth/identity.ts +140 -2
- package/src/auth/index.ts +17 -1
- package/src/auth/method-context.ts +3 -0
- package/src/auth/oauth-as/cimd.ts +132 -0
- package/src/auth/oauth-as/crypto.test.ts +101 -0
- package/src/auth/oauth-as/crypto.ts +393 -0
- package/src/auth/oauth-as/errors.ts +68 -0
- package/src/auth/oauth-as/http.test.ts +419 -0
- package/src/auth/oauth-as/http.ts +842 -0
- package/src/auth/oauth-as/stores.ts +61 -0
- package/src/auth/oauth-as/tables.ts +142 -0
- package/src/auth/tables.ts +0 -11
- package/src/bench/README.md +83 -0
- package/src/bench/REPORT.md +176 -0
- package/src/bench/g01-rls-stamp.bench.ts +194 -0
- package/src/bench/g02-clock-per-tenant.bench.ts +158 -0
- package/src/bench/g03-signal-once.bench.ts +157 -0
- package/src/bench/g03-signal-reconnect.bench.ts +254 -0
- package/src/bench/g03-signal-sse-memory.bench.ts +191 -0
- package/src/bench/g04-auth-vault-hotpath.bench.ts +170 -0
- package/src/bench/g05-sustained-full.bench.ts +265 -0
- package/src/bench/g06-mixed-load.bench.ts +260 -0
- package/src/bench/g07-vault-crypto.bench.ts +100 -0
- package/src/bench/g07-vault-rotate-under-read.bench.ts +285 -0
- package/src/bench/g08-conn-oversubscribe.bench.ts +194 -0
- package/src/bench/g08-store-kv-durable.bench.ts +133 -0
- package/src/bench/g08-store-sql.bench.ts +178 -0
- package/src/bench/g09-journal-sustained.bench.ts +203 -0
- package/src/bench/g10-observability-contention.bench.ts +246 -0
- package/src/bench/g11-cold-start-cycle.bench.ts +164 -0
- package/src/bench/g13-elements.bench.ts +427 -0
- package/src/bench/g14-graceful-shutdown.bench.ts +244 -0
- package/src/bench/g15-postgres-degradation.bench.ts +264 -0
- package/src/bench/g16-live-query-fanout.bench.ts +206 -0
- package/src/bench/lib/event-loop-lag.ts +26 -0
- package/src/bench/lib/infra.ts +60 -0
- package/src/bench/lib/report.ts +52 -0
- package/src/bench/lib/rss-sampler.ts +61 -0
- package/src/bench/lib/signal-pg.ts +88 -0
- package/src/bench/load-app.ts +337 -0
- package/src/bench/load-child.ts +108 -0
- package/src/bench/smoke.bench.ts +43 -0
- package/src/cli/competitor-mention-removal.test.ts +28 -0
- package/src/cli/doctor-fd.ts +117 -0
- package/src/cli/doctor.test.ts +192 -0
- package/src/cli/doctor.ts +129 -1
- package/src/client/create.ts +95 -1
- package/src/client/index.ts +9 -2
- package/src/client/transport.ts +11 -4
- package/src/client/use-live-query.ts +154 -0
- package/src/client-react/index.ts +15 -1
- package/src/client-react/live-resource.ts +246 -0
- package/src/client-react/use-live-query.test.ts +475 -0
- package/src/client-react/use-live-query.ts +530 -0
- package/src/compiler/extract.test.ts +518 -0
- package/src/compiler/extract.ts +386 -19
- package/src/console/server/invoke-user-flow.ts +2 -1
- package/src/console/ui-next/dist/assets/{access-page-DnWbnGzq.js → access-page-De7Lc2JC.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-BiZ4-6yQ.js → flows-page-RGy7VEA_.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-C8NRK2R-.js → index-_rgpdVzo.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-CrB6vd1T.js → observability-page-Ds6pcnh-.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-CS5-aETQ.js → store-page-02xOiqIK.js} +3 -3
- package/src/console/ui-next/dist/assets/{units-page-CjtdlW8l.js → units-page-4rHOePuE.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-C6Xxm9SA.js → vault-page-DISPgxLM.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/console/ui-next/src/features/store/lib/fields-from-table.ts +36 -2
- package/src/drivers/cdc-outbox.ts +389 -0
- package/src/drivers/memory.ts +20 -0
- package/src/drivers/oauth-apple.ts +156 -0
- package/src/drivers/oauth-discord.ts +79 -0
- package/src/drivers/oauth-facebook.ts +80 -0
- package/src/drivers/oauth-figma.ts +116 -0
- package/src/drivers/oauth-github.ts +92 -0
- package/src/drivers/oauth-google.ts +142 -0
- package/src/drivers/oauth-microsoft.ts +174 -0
- package/src/drivers/oauth-oidc.ts +293 -0
- package/src/drivers/oauth-shared.ts +326 -0
- package/src/drivers/oauth-types.ts +159 -0
- package/src/drivers/oauth-x.ts +77 -0
- package/src/drivers/oauth2-common.ts +95 -0
- package/src/drivers/oauth2-token.ts +61 -0
- package/src/drivers/pg-rls-row-passes.ts +251 -0
- package/src/drivers/pg-rls.ts +2 -0
- package/src/drivers/postgres.ts +45 -2
- package/src/drivers/signal-postgres.ts +2 -1
- package/src/elements/channel/runtime.ts +29 -2
- package/src/elements/channel.test.ts +52 -0
- package/src/elements/gate/boot.ts +29 -2
- package/src/elements/store/emit-drizzle.ts +147 -14
- package/src/elements/store/field-ddl.test.ts +118 -0
- package/src/elements/store/field-types.test.ts +455 -0
- package/src/elements/store/list-query.golden.json +777 -0
- package/src/elements/store/list-query.parity.test.ts +396 -0
- package/src/elements/store/list-query.ts +792 -0
- package/src/elements/store/live-default.test.ts +136 -0
- package/src/elements/store/live-http.test.ts +160 -0
- package/src/elements/store/live-isolation.test.ts +291 -0
- package/src/elements/store/live-query-runtime.test.ts +323 -0
- package/src/elements/store/live-query-runtime.ts +403 -0
- package/src/elements/store/live-query-server.test.ts +377 -0
- package/src/elements/store/live-query-server.ts +102 -0
- package/src/elements/store/live-query.ts +97 -0
- package/src/elements/store/resource.ts +189 -680
- package/src/elements/store/rls-row-passes-policies.parity.test.ts +665 -0
- package/src/elements/store/schema-decl.ts +539 -41
- package/src/elements/store/sql-rls-stamp.test.ts +27 -0
- package/src/elements/store/sql-session.ts +297 -35
- package/src/elements/store/table.ts +102 -21
- package/src/elements/store.test.ts +3 -1
- package/src/elements/store.ts +12 -1
- package/src/elements/vault/chaos-child.ts +74 -1
- package/src/elements/vault/chaos.test.ts +4 -2
- package/src/elements/vault/storage.ts +4 -2
- package/src/index.ts +5 -2
- package/src/kernel/app-auth.ts +1 -0
- package/src/kernel/app.ts +116 -2
- package/src/kernel/auth-sharing.test.ts +196 -0
- package/src/kernel/boot.test.ts +3 -3
- package/src/kernel/errors.ts +8 -0
- package/src/kernel/fx.test.ts +1 -0
- package/src/kernel/fx.ts +14 -2
- package/src/kernel/horizontal-child.ts +2 -1
- package/src/kernel/http-resource.ts +33 -7
- package/src/kernel/identity-host-persist.test.ts +119 -0
- package/src/kernel/instance-id.ts +4 -2
- package/src/kernel/journal.ts +2 -1
- package/src/kernel/mcp-tool.test.ts +95 -0
- package/src/kernel/on.ts +9 -0
- package/src/kernel/realtime-bind.ts +326 -0
- package/src/kernel/resource-live.ts +117 -0
- package/src/kernel/triggers.ts +86 -4
- package/src/manifest/diff.ts +37 -0
- package/src/manifest/types.ts +64 -2
- package/src/okid.bench.test.ts +64 -0
- package/src/okid.test.ts +338 -0
- package/src/okid.ts +245 -0
- package/src/plugins/anonymous.ts +19 -1
- package/src/plugins/auth/shared.ts +15 -0
- package/src/plugins/index.ts +2 -0
- package/src/plugins/magic-link.ts +10 -8
- package/src/plugins/mcp-oauth.ts +208 -0
- package/src/plugins/oauth/flow-store.ts +117 -0
- package/src/plugins/oauth/link.ts +69 -0
- package/src/plugins/oauth/shared.ts +108 -0
- package/src/plugins/oauth/token-vault.ts +100 -0
- package/src/plugins/oauth.security.test.ts +535 -0
- package/src/plugins/oauth.ts +532 -0
- package/src/plugins/otp.ts +48 -6
- package/src/plugins/passkey.ts +20 -1
- package/src/plugins/two-factor.ts +11 -0
- package/src/plugins/username.ts +40 -7
- package/src/release/build-lib.ts +7 -1
- package/src/release/measure.ts +1 -0
- package/src/release/official-plugins.ts +4 -1
- package/src/runs/collect.ts +2 -1
- package/src/runs/drivers/files.ts +2 -1
- package/src/test/create-test-app.ts +114 -5
- package/src/test/export-bundle.test.ts +33 -0
- package/src/test/live-signals.test.ts +83 -0
- package/src/test/tenant-isolation.test.ts +175 -0
- package/src/testing.ts +26 -0
- package/src/upgrade/codemods.ts +1 -1
- package/site/content/docs/reference/migrating-environments.mdx +0 -158
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boot-time Realtime wire-up (Phase 2 of the realtime round).
|
|
3
|
+
*
|
|
4
|
+
* When the booted Store exposes a primary SQL connection on a
|
|
5
|
+
* `row_passes_policies`-capable driver (`postgres` / `pglite`):
|
|
6
|
+
* 1. Install the process-wide `sql-session` CDC sink → LiveQuery runtime
|
|
7
|
+
* ingest, outbox append (multi-host durability), and user-declared CDC
|
|
8
|
+
* flow dispatch.
|
|
9
|
+
* 2. Start a {@link CdcOutboxRunner} SKIP LOCKED poller draining durable
|
|
10
|
+
* events back into the same runtime.
|
|
11
|
+
* 3. Register each subscribed table's live signal on the Signal bus so
|
|
12
|
+
* existing `fx.live` SSE routes deliver classified events to clients.
|
|
13
|
+
*
|
|
14
|
+
* Torn down by {@link unbindRealtimeBridge} on app stop.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { CdcOutbox, CdcOutboxRunner, type OutboxRow } from "../drivers/cdc-outbox.ts";
|
|
18
|
+
import { RLS_CONTEXT_DRIVERS, type RlsIdentity } from "../drivers/pg-rls.ts";
|
|
19
|
+
import type { SqlConnection } from "../drivers/types.ts";
|
|
20
|
+
import { liveQueryRuntimeFromConn } from "../elements/store/live-query-server.ts";
|
|
21
|
+
import { setSqlCdcSink, type SqlCdcSink } from "../elements/store/sql-session.ts";
|
|
22
|
+
import type { LiveQueryEvent, LiveSubscription } from "../elements/store/live-query-runtime.ts";
|
|
23
|
+
import { currentAbortSignal, linkAbort } from "./abort-scope.ts";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Column-kind hint for one SQL column — drives {@link restoreImage} type
|
|
27
|
+
* restoration for outbox-round-tripped CDC images.
|
|
28
|
+
*/
|
|
29
|
+
export type LiveColumnKind = "string" | "number" | "boolean";
|
|
30
|
+
|
|
31
|
+
/** Internal Signal name prefix carrying classified live-query events. */
|
|
32
|
+
const LIVE_SIGNAL_PREFIX = "oke/live/sql:";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Reserved live-signal name for one table. Exported for compiler synthesis —
|
|
36
|
+
* manifest effects reference the exact same string.
|
|
37
|
+
*
|
|
38
|
+
* @param table - Physical table name
|
|
39
|
+
*/
|
|
40
|
+
export function liveSignalName(table: string): string {
|
|
41
|
+
return `${LIVE_SIGNAL_PREFIX}${table}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Mutation header echoed into `mutationId` on write-path events. */
|
|
45
|
+
export const MUTATION_ID_HEADER = "x-oke-mutation-id";
|
|
46
|
+
|
|
47
|
+
/** Per-stream event buffer cap (bounded memory per SSE session). */
|
|
48
|
+
const LIVE_STREAM_BUFFER_MAX = 512;
|
|
49
|
+
|
|
50
|
+
/** Active bridge state (singleton per process). */
|
|
51
|
+
let active: {
|
|
52
|
+
/** Primary connection the runtime / outbox classify and poll against. */
|
|
53
|
+
readonly primary: SqlConnection;
|
|
54
|
+
readonly runtime: ReturnType<typeof liveQueryRuntimeFromConn>;
|
|
55
|
+
readonly outbox: CdcOutbox;
|
|
56
|
+
readonly runner?: CdcOutboxRunner;
|
|
57
|
+
} | null = null;
|
|
58
|
+
|
|
59
|
+
/** Delivery subscription for one SSE client (returned to `fx.live` glue). */
|
|
60
|
+
export interface LiveSubscriptionHandle {
|
|
61
|
+
/** Push one classified event to this subscriber's stream. */
|
|
62
|
+
deliver(event: LiveQueryEvent): void;
|
|
63
|
+
/** Detach from fan-out (idempotent). */
|
|
64
|
+
unsubscribe(): void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Wire the realtime stack for this process. Idempotent for the **same**
|
|
69
|
+
* primary connection (test graphs re-`oke()` freely). A different primary
|
|
70
|
+
* (e.g. a fresh PGLite instance on the next test) tears down the old bridge
|
|
71
|
+
* and rebinds — otherwise classification probes the previous DB and live
|
|
72
|
+
* SSE frames never fire.
|
|
73
|
+
*
|
|
74
|
+
* @param primary - Shared primary SQL connection (postgres/pglite only)
|
|
75
|
+
* @param dispatchCdc - App-level dispatcher for user-declared CDC flows
|
|
76
|
+
* @returns The process runtime, or `null` when the SQL driver cannot host it
|
|
77
|
+
*/
|
|
78
|
+
export function bindRealtimeBridge(
|
|
79
|
+
primary: SqlConnection,
|
|
80
|
+
dispatchCdc: (
|
|
81
|
+
tableName: string,
|
|
82
|
+
payload: { readonly before: unknown; readonly after: unknown },
|
|
83
|
+
column?: string,
|
|
84
|
+
) => Promise<unknown[]> | unknown[],
|
|
85
|
+
): ReturnType<typeof liveQueryRuntimeFromConn> | null {
|
|
86
|
+
if (!RLS_CONTEXT_DRIVERS.has(primary.driverId)) return null;
|
|
87
|
+
if (active) {
|
|
88
|
+
if (active.primary === primary) return active.runtime;
|
|
89
|
+
unbindRealtimeBridge();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const runtime = liveQueryRuntimeFromConn(primary);
|
|
93
|
+
const outbox = new CdcOutbox(primary);
|
|
94
|
+
// First tick creates the table + indexes without blocking boot.
|
|
95
|
+
void outbox.ensure().catch(() => undefined);
|
|
96
|
+
const sink: SqlCdcSink = (event) => {
|
|
97
|
+
// In-process leg first — classification needs no outbox round-trip.
|
|
98
|
+
runtime.onCdc(event);
|
|
99
|
+
// Durable multi-host leg: other hosts drain via the poller. Local events
|
|
100
|
+
// are also appended so a restarted process replays un-delivered rows;
|
|
101
|
+
// the poller redelivery is idempotent at the client by PK+seq.
|
|
102
|
+
void outbox.append(event).catch(() => undefined);
|
|
103
|
+
void dispatchCdc(event.tableName, { before: event.before, after: event.after });
|
|
104
|
+
};
|
|
105
|
+
setSqlCdcSink(sink);
|
|
106
|
+
|
|
107
|
+
const runner = new CdcOutboxRunner(
|
|
108
|
+
outbox,
|
|
109
|
+
async (rows: readonly OutboxRow[]) => {
|
|
110
|
+
for (const row of rows) {
|
|
111
|
+
runtime.onCdc({
|
|
112
|
+
tableName: row.tableName,
|
|
113
|
+
op: row.op,
|
|
114
|
+
before: row.before,
|
|
115
|
+
after: row.after,
|
|
116
|
+
...(Number.isFinite(row.seq) ? { seq: row.seq } : {}),
|
|
117
|
+
...(row.mutationId !== undefined ? { mutationId: row.mutationId } : {}),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
{},
|
|
122
|
+
);
|
|
123
|
+
runner.start();
|
|
124
|
+
|
|
125
|
+
active = { primary, runtime, outbox, runner };
|
|
126
|
+
return runtime;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Push→pull bridge: convert runtime `deliver` callbacks into an SSE async
|
|
131
|
+
* iterable. Bounded per-stream buffer — a slow client sheds its own events
|
|
132
|
+
* (silently stale until resync; the v1 documented trade-off) instead of
|
|
133
|
+
* pinning the fan-out worker pool.
|
|
134
|
+
*/
|
|
135
|
+
export interface LiveEventStream {
|
|
136
|
+
/** Classified events as SSE frames (already JSON-shaped). */
|
|
137
|
+
readonly chunks: AsyncIterable<{ readonly data: LiveQueryEvent }>;
|
|
138
|
+
/** Stop fan-out and close the iterator (idempotent). */
|
|
139
|
+
close(): Promise<void>;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Options for {@link openLiveStream} — one SSE subscriber's window. */
|
|
143
|
+
export interface OpenLiveStreamOptions {
|
|
144
|
+
/** Physical table name. */
|
|
145
|
+
readonly table: string;
|
|
146
|
+
/** Subscriber stamp (gate/user/scopes/tenant). */
|
|
147
|
+
readonly identity: RlsIdentity;
|
|
148
|
+
/** Primary key column. */
|
|
149
|
+
readonly pkColumn: string;
|
|
150
|
+
/** List query window compiled to SQL (`?` placeholders), when any. */
|
|
151
|
+
readonly whereSql?: string;
|
|
152
|
+
readonly whereParams?: readonly unknown[];
|
|
153
|
+
/**
|
|
154
|
+
* Column-kind hints for JSONB image restoration on outbox replay
|
|
155
|
+
* (`jsKey → primitive kind`). Optional.
|
|
156
|
+
*/
|
|
157
|
+
readonly tableColumns?: Readonly<Record<string, LiveColumnKind>>;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Subscribe this SSE session to classified live events for one table.
|
|
161
|
+
*
|
|
162
|
+
* The returned handle implements the {@link Fx} live-stream physics without
|
|
163
|
+
* touching a signal tape: per-subscriber classification makes shared-tape
|
|
164
|
+
* delivery impossible to do safely — each identity's verdict differs.
|
|
165
|
+
*
|
|
166
|
+
* Links the ambient structured-concurrency abort signal: when the request
|
|
167
|
+
* branch aborts (client disconnect), the subscription detaches itself —
|
|
168
|
+
* the same ALS physics `fx.live` SSE streams use.
|
|
169
|
+
*
|
|
170
|
+
* @param id - Stable stream id (used for registry dedupe + metrics)
|
|
171
|
+
* @param options - Table, stamp, query window, column hints
|
|
172
|
+
*/
|
|
173
|
+
export function openLiveStream(
|
|
174
|
+
id: string,
|
|
175
|
+
options: OpenLiveStreamOptions,
|
|
176
|
+
runtimeOverride?: ReturnType<typeof liveQueryRuntimeFromConn>,
|
|
177
|
+
): LiveEventStream {
|
|
178
|
+
const runtime = runtimeOverride ?? active?.runtime;
|
|
179
|
+
if (!runtime) throw new Error("live subscribe before realtime bridge bind");
|
|
180
|
+
const buffer: { readonly data: LiveQueryEvent }[] = [];
|
|
181
|
+
let woke = false;
|
|
182
|
+
let done = false;
|
|
183
|
+
let notify: (() => void) | undefined;
|
|
184
|
+
const wake = (): void => {
|
|
185
|
+
if (!woke || done) return;
|
|
186
|
+
woke = false;
|
|
187
|
+
const n = notify;
|
|
188
|
+
notify = undefined;
|
|
189
|
+
n?.();
|
|
190
|
+
};
|
|
191
|
+
const sub: LiveSubscription = {
|
|
192
|
+
...options,
|
|
193
|
+
id,
|
|
194
|
+
ref: `sql:${options.table}`,
|
|
195
|
+
deliver(event) {
|
|
196
|
+
if (done) return;
|
|
197
|
+
// Per-stream cap: bounded memory per subscriber regardless of churn.
|
|
198
|
+
if (buffer.length >= LIVE_STREAM_BUFFER_MAX) {
|
|
199
|
+
// Shed from the front — the oldest missed event is re-fetchable via
|
|
200
|
+
// list refetch; newer state wins for eventual convergence.
|
|
201
|
+
buffer.shift();
|
|
202
|
+
}
|
|
203
|
+
buffer.push({ data: event });
|
|
204
|
+
wake();
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
const unsubscribe = runtime.subscribe(sub);
|
|
208
|
+
const chunks: AsyncIterable<{ readonly data: LiveQueryEvent }> = {
|
|
209
|
+
[Symbol.asyncIterator]() {
|
|
210
|
+
return {
|
|
211
|
+
async next(): Promise<IteratorResult<{ readonly data: LiveQueryEvent }>> {
|
|
212
|
+
for (;;) {
|
|
213
|
+
const frame = buffer.shift();
|
|
214
|
+
if (frame !== undefined) return { done: false as const, value: frame };
|
|
215
|
+
if (done) return { done: true as const, value: undefined };
|
|
216
|
+
await new Promise<void>((resolve) => {
|
|
217
|
+
notify = resolve;
|
|
218
|
+
woke = true;
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
async return(): Promise<IteratorResult<{ readonly data: LiveQueryEvent }>> {
|
|
223
|
+
await close();
|
|
224
|
+
return { done: true as const, value: undefined };
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
// Ambient-branch abort (client disconnect) detaches the subscription —
|
|
230
|
+
// same ALS physics `createLiveStream` uses for `fx.live` SSE.
|
|
231
|
+
const local = new AbortController();
|
|
232
|
+
const unlink = linkAbort(currentAbortSignal(), local);
|
|
233
|
+
const onAbort = (): void => {
|
|
234
|
+
void close();
|
|
235
|
+
};
|
|
236
|
+
local.signal.addEventListener("abort", onAbort, { once: true });
|
|
237
|
+
|
|
238
|
+
async function close(): Promise<void> {
|
|
239
|
+
if (done) return;
|
|
240
|
+
done = true;
|
|
241
|
+
unsubscribe();
|
|
242
|
+
local.signal.removeEventListener("abort", onAbort);
|
|
243
|
+
unlink();
|
|
244
|
+
wake();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return { chunks, close };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Register an SSE-side subscription without opening a stream. Kept for
|
|
252
|
+
* transport tests that drive `deliver` manually.
|
|
253
|
+
*
|
|
254
|
+
* @param table - Physical table name
|
|
255
|
+
* @param id - Stable stream/session id
|
|
256
|
+
* @param identity - Subscriber stamp
|
|
257
|
+
* @param sub - Query window + delivery sink
|
|
258
|
+
*/
|
|
259
|
+
export function subscribeLive(
|
|
260
|
+
table: string,
|
|
261
|
+
id: string,
|
|
262
|
+
identity: RlsIdentity,
|
|
263
|
+
sub: {
|
|
264
|
+
readonly pkColumn: string;
|
|
265
|
+
readonly whereSql?: string;
|
|
266
|
+
readonly whereParams?: readonly unknown[];
|
|
267
|
+
deliver(event: LiveQueryEvent): void;
|
|
268
|
+
},
|
|
269
|
+
): LiveSubscriptionHandle {
|
|
270
|
+
if (!active) throw new Error("live subscribe before realtime bridge bind");
|
|
271
|
+
const unsub = active.runtime.subscribe({ ...sub, id, ref: `sql:${table}`, table, identity });
|
|
272
|
+
return { deliver: sub.deliver, unsubscribe: unsub };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Tear down polling and the process-wide sink (app stop). */
|
|
276
|
+
export function unbindRealtimeBridge(): void {
|
|
277
|
+
if (!active) return;
|
|
278
|
+
active.runner?.stop();
|
|
279
|
+
setSqlCdcSink(null);
|
|
280
|
+
active = null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Test/doctor visibility into the bound bridge. */
|
|
284
|
+
export function realtimeBridgeRuntime(): ReturnType<typeof liveQueryRuntimeFromConn> | undefined {
|
|
285
|
+
return active?.runtime;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Realtime observability snapshot (metrics / doctor / Console):
|
|
290
|
+
*
|
|
291
|
+
* - `subscribers` — active live subscriptions right now
|
|
292
|
+
* - `queueDepth` — pending fan-out jobs (architecture contract gauge)
|
|
293
|
+
* - `fanout` — LiveQuery runtime counters (events in/shed, checks run/failed)
|
|
294
|
+
* - `outbox` — pending rows + delivered-over-cap gauge, plus runner counters
|
|
295
|
+
* (`eventsDrained`, `pruneFailures`, …) when the poller leg is bound
|
|
296
|
+
*
|
|
297
|
+
* Returns `null` when no realtime bridge is bound (no Postgres-capable
|
|
298
|
+
* primary SQL driver, or app already stopped).
|
|
299
|
+
*/
|
|
300
|
+
export async function realtimeMetrics(): Promise<{
|
|
301
|
+
subscribers: number;
|
|
302
|
+
queueDepth: number;
|
|
303
|
+
fanout: { eventsIn: number; eventsShed: number; checksRun: number; checkFailures: number };
|
|
304
|
+
outbox:
|
|
305
|
+
| {
|
|
306
|
+
pending: number;
|
|
307
|
+
dispatchedOverCap: number;
|
|
308
|
+
runner?: { polls: number; batches: number; eventsDrained: number; pruneFailures: number };
|
|
309
|
+
}
|
|
310
|
+
| { unavailable: "no_bridge" };
|
|
311
|
+
} | null> {
|
|
312
|
+
if (!active) return null;
|
|
313
|
+
const outboxStats = await active.outbox.stats().catch(() => null);
|
|
314
|
+
return {
|
|
315
|
+
subscribers: active.runtime.size,
|
|
316
|
+
queueDepth: active.runtime.queueDepth,
|
|
317
|
+
fanout: { ...active.runtime.metrics },
|
|
318
|
+
outbox:
|
|
319
|
+
outboxStats === null
|
|
320
|
+
? { unavailable: "no_bridge" as const }
|
|
321
|
+
: {
|
|
322
|
+
...outboxStats,
|
|
323
|
+
...(active.runner !== undefined ? { runner: { ...active.runner.metrics } } : {}),
|
|
324
|
+
},
|
|
325
|
+
};
|
|
326
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project-wide `store.live` default — deferred live-query mounts.
|
|
3
|
+
*
|
|
4
|
+
* `oke({ store: { live: true } })` flips the default posture for NEW
|
|
5
|
+
* `store.schema.table()` declarations to live-by-default. `store.resource`
|
|
6
|
+
* and `http.resource` both run at module evaluation, before `oke()` is
|
|
7
|
+
* constructed, so the flag is not yet known when they execute. This module
|
|
8
|
+
* records their pending live surfaces and mount slots; the `oke()`
|
|
9
|
+
* constructor drains them and adopts the synthesized `GET <path>/live`
|
|
10
|
+
* bindings only when the flag is on. When the flag is off, the pending
|
|
11
|
+
* entries are discarded — 100% of today's explicit-only behavior.
|
|
12
|
+
*
|
|
13
|
+
* Declaration ergonomics only: draining mounts the same
|
|
14
|
+
* `store.resource({ live: true })` live surface. It never changes the
|
|
15
|
+
* underlying CDC / RLS-per-event cost model.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createHttpTrigger, type GateRef } from "./triggers.ts";
|
|
19
|
+
import type { AnyFlowDef } from "./flow.ts";
|
|
20
|
+
import type { Binding } from "./on.ts";
|
|
21
|
+
|
|
22
|
+
/** Marker placed on resource ops bags whose live surface is flag-dependent. */
|
|
23
|
+
export const PENDING_RESOURCE_LIVE: unique symbol = Symbol.for("oke.resource.pending-live");
|
|
24
|
+
|
|
25
|
+
/** A lazily-built live surface for a resource whose `live` was omitted. */
|
|
26
|
+
export interface PendingResourceLive {
|
|
27
|
+
/** Signal name — `oke/live/sql:<table>`. */
|
|
28
|
+
readonly signalName: string;
|
|
29
|
+
/** Resource definition to stamp `live` onto when the flag resolves on. */
|
|
30
|
+
readonly def: object;
|
|
31
|
+
/** Ops bag (`resource.all()`) to stamp `live` onto when resolved on. */
|
|
32
|
+
readonly bag: object;
|
|
33
|
+
/** Build the surface on demand (never called when the flag stays off). */
|
|
34
|
+
readonly build: () => { readonly signal: string; readonly flow: AnyFlowDef };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Mount slot left by `http.resource` for a resource with a pending surface. */
|
|
38
|
+
export interface PendingResourceLiveMount {
|
|
39
|
+
readonly path: string;
|
|
40
|
+
readonly gates: readonly GateRef[];
|
|
41
|
+
readonly signalName: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const pendingSurfaces = new Map<string, PendingResourceLive>();
|
|
45
|
+
const pendingMounts = new Map<string, PendingResourceLiveMount>();
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Record a resource whose `live` was omitted (flag-dependent).
|
|
49
|
+
*
|
|
50
|
+
* @param surface - Pending live surface
|
|
51
|
+
*/
|
|
52
|
+
export function registerPendingResourceLive(surface: PendingResourceLive): void {
|
|
53
|
+
pendingSurfaces.set(surface.signalName, surface);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Record a `http.resource(path, ops)` mount slot whose ops bag carries a
|
|
58
|
+
* pending live surface. Keyed by path so gate-chaining rebuilds overwrite
|
|
59
|
+
* earlier partial-gate registrations.
|
|
60
|
+
*
|
|
61
|
+
* @param mount - Path, gates, and the pending surface's signal name
|
|
62
|
+
*/
|
|
63
|
+
export function registerPendingResourceLiveMount(mount: PendingResourceLiveMount): void {
|
|
64
|
+
pendingMounts.set(mount.path, mount);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolve every pending mount. Always clears the pending state; bindings
|
|
69
|
+
* are only emitted (and surfaces built) when `enabled` is true.
|
|
70
|
+
*
|
|
71
|
+
* @param enabled - Whether the project-wide `store.live` default is on
|
|
72
|
+
* @param adopt - Adopt one binding (`oke`'s `adoptBinding`)
|
|
73
|
+
* @returns The adopted bindings (empty when the flag is off)
|
|
74
|
+
*/
|
|
75
|
+
export function drainPendingResourceLiveMounts(
|
|
76
|
+
enabled: boolean,
|
|
77
|
+
adopt: (binding: Binding) => void,
|
|
78
|
+
): Binding[] {
|
|
79
|
+
const mounts = [...pendingMounts.values()];
|
|
80
|
+
pendingMounts.clear();
|
|
81
|
+
const adopted: Binding[] = [];
|
|
82
|
+
if (!enabled) {
|
|
83
|
+
pendingSurfaces.clear();
|
|
84
|
+
return adopted;
|
|
85
|
+
}
|
|
86
|
+
for (const mount of mounts) {
|
|
87
|
+
const surface = pendingSurfaces.get(mount.signalName);
|
|
88
|
+
if (!surface) continue;
|
|
89
|
+
const built = surface.build();
|
|
90
|
+
const liveFlow = built.flow as AnyFlowDef & { live?: string };
|
|
91
|
+
// Match what `on(http.get(...).live(signal), flow)` stamps on the flow —
|
|
92
|
+
// `flow.live` is the signal name the Manifest / client tooling reads.
|
|
93
|
+
liveFlow.live = mount.signalName;
|
|
94
|
+
// Stamp the resolved surface onto the resource + bag so post-construction
|
|
95
|
+
// `resource.live` reads are precise per object (never a global flag).
|
|
96
|
+
Object.defineProperty(surface.def, "live", { value: built, configurable: true });
|
|
97
|
+
Object.defineProperty(surface.bag, "live", { value: built, configurable: true });
|
|
98
|
+
const binding: Binding = {
|
|
99
|
+
trigger: createHttpTrigger("GET", `${mount.path}/live`, mount.gates, {
|
|
100
|
+
name: mount.signalName,
|
|
101
|
+
}),
|
|
102
|
+
flow: built.flow,
|
|
103
|
+
};
|
|
104
|
+
adopt(binding);
|
|
105
|
+
adopted.push(binding);
|
|
106
|
+
}
|
|
107
|
+
pendingSurfaces.clear();
|
|
108
|
+
return adopted;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Clear pending live state without draining (test reset).
|
|
113
|
+
*/
|
|
114
|
+
export function resetPendingResourceLive(): void {
|
|
115
|
+
pendingMounts.clear();
|
|
116
|
+
pendingSurfaces.clear();
|
|
117
|
+
}
|
package/src/kernel/triggers.ts
CHANGED
|
@@ -54,6 +54,15 @@ export interface HttpTrigger<M extends HttpMethod = HttpMethod, P extends string
|
|
|
54
54
|
* @param signal - Live signal handle
|
|
55
55
|
*/
|
|
56
56
|
live(signal: SignalSource): LiveHttpTrigger<M, P>;
|
|
57
|
+
/**
|
|
58
|
+
* Declare a live query surface over a table on this GET: same CDC +
|
|
59
|
+
* per-subscriber RLS classification physics as
|
|
60
|
+
* `store.resource(…, { live: true })`, for a hand-written flow. The
|
|
61
|
+
* flow body opens the stream via `liveQuery(fx, table, input)`.
|
|
62
|
+
*
|
|
63
|
+
* @param table - `store.schema.table` (or drizzle/table handle) binding
|
|
64
|
+
*/
|
|
65
|
+
live(table: object): LiveHttpTrigger<M, P>;
|
|
57
66
|
}
|
|
58
67
|
|
|
59
68
|
/**
|
|
@@ -107,8 +116,30 @@ export interface InternalTrigger {
|
|
|
107
116
|
readonly kind: "internal";
|
|
108
117
|
}
|
|
109
118
|
|
|
119
|
+
/**
|
|
120
|
+
* MCP tool exposure — the per-flow opt-in surface for OAuth-protected
|
|
121
|
+
* user-plane tools (`on(mcp.tool("bookings.create").gate(...), flow)`).
|
|
122
|
+
*
|
|
123
|
+
* Deny-by-default: a flow without this trigger is never listed over MCP.
|
|
124
|
+
* Gates are required (typically `gate.auth` + `gate.scope(...)`) exactly
|
|
125
|
+
* like sensitive HTTP routes.
|
|
126
|
+
*/
|
|
127
|
+
export interface McpToolTrigger {
|
|
128
|
+
readonly kind: "mcp";
|
|
129
|
+
/** Tool name exposed in MCP `tools/list`. */
|
|
130
|
+
readonly name: string;
|
|
131
|
+
readonly gates: readonly GateRef[];
|
|
132
|
+
readonly gate: GateAttach<McpToolTrigger>;
|
|
133
|
+
}
|
|
134
|
+
|
|
110
135
|
/** Discriminated union of all trigger kinds. */
|
|
111
|
-
export type Trigger =
|
|
136
|
+
export type Trigger =
|
|
137
|
+
| HttpTrigger
|
|
138
|
+
| EveryTrigger
|
|
139
|
+
| SignalAsTrigger
|
|
140
|
+
| CdcTrigger
|
|
141
|
+
| InternalTrigger
|
|
142
|
+
| McpToolTrigger;
|
|
112
143
|
|
|
113
144
|
/** Trigger kind string. */
|
|
114
145
|
export type TriggerKind = Trigger["kind"];
|
|
@@ -162,8 +193,19 @@ export function createHttpTrigger<M extends HttpMethod, P extends string>(
|
|
|
162
193
|
public() {
|
|
163
194
|
return createHttpTrigger(method, path, [...gates, GATE_PUBLIC_NAME], liveSignal);
|
|
164
195
|
},
|
|
165
|
-
live(
|
|
166
|
-
|
|
196
|
+
live(source: SignalSource | object) {
|
|
197
|
+
if (typeof source === "object" && !("name" in source)) {
|
|
198
|
+
// Table binding — declare a live query surface over this table.
|
|
199
|
+
// Same signal name the `store.resource` live surface synthesizes;
|
|
200
|
+
// resolved lazily so triggers.ts stays free of store imports.
|
|
201
|
+
const storeTable = lazyRequire<typeof import("../elements/store/table.ts")>(
|
|
202
|
+
"../elements/store",
|
|
203
|
+
"table",
|
|
204
|
+
);
|
|
205
|
+
const signal = { name: `oke/live/sql:${storeTable.resolveTableName(source)}` };
|
|
206
|
+
return createHttpTrigger(method, path, gates, signal);
|
|
207
|
+
}
|
|
208
|
+
return createHttpTrigger(method, path, gates, source as SignalSource);
|
|
167
209
|
},
|
|
168
210
|
};
|
|
169
211
|
return trigger;
|
|
@@ -179,6 +221,8 @@ export interface ResourceFlowBag {
|
|
|
179
221
|
readonly get: unknown;
|
|
180
222
|
readonly update: unknown;
|
|
181
223
|
readonly remove: unknown;
|
|
224
|
+
/** Live surface when present — `store.resource(…, { live: true }).all()`. */
|
|
225
|
+
readonly live?: { readonly signal: string; readonly flow: unknown };
|
|
182
226
|
}
|
|
183
227
|
|
|
184
228
|
/**
|
|
@@ -190,6 +234,12 @@ export interface ResourceMount {
|
|
|
190
234
|
readonly [resourceMountBrand]: true;
|
|
191
235
|
readonly mounts: ReadonlyArray<{ readonly trigger: HttpTrigger; readonly flow: unknown }>;
|
|
192
236
|
readonly gates: readonly GateRef[];
|
|
237
|
+
/**
|
|
238
|
+
* Optional live surface — set by `httpResource` when the ops bag carries
|
|
239
|
+
* `live` (from `store.resource(…, { live: true }).all()`). `on()` then
|
|
240
|
+
* synthesizes the SSE exposure on `<path>/live`.
|
|
241
|
+
*/
|
|
242
|
+
readonly live?: { readonly signal: string; readonly flow: unknown };
|
|
193
243
|
/**
|
|
194
244
|
* Attach gates to every verb (registration order). `gate.all` and arrays flatten.
|
|
195
245
|
*/
|
|
@@ -411,6 +461,37 @@ export function table(name: string, store?: string): TableHandle {
|
|
|
411
461
|
*/
|
|
412
462
|
export const internal: InternalTrigger = { kind: "internal" };
|
|
413
463
|
|
|
464
|
+
/**
|
|
465
|
+
* MCP tool namespace — `mcp.tool("bookings.create")` marks a flow as an
|
|
466
|
+
* explicitly exposed MCP tool for OAuth user-plane clients. Chain `.gate(...)`
|
|
467
|
+
* like HTTP triggers; no gates ⇒ not exposed (deny-by-default).
|
|
468
|
+
*
|
|
469
|
+
* @param name - Tool name (namespaced, e.g. `bookings.create`)
|
|
470
|
+
*/
|
|
471
|
+
export const mcp: {
|
|
472
|
+
/**
|
|
473
|
+
* @param name - Tool name
|
|
474
|
+
*/
|
|
475
|
+
tool(name: string): McpToolTrigger;
|
|
476
|
+
} = {
|
|
477
|
+
tool(name: string): McpToolTrigger {
|
|
478
|
+
if (typeof name !== "string" || name.trim().length === 0) {
|
|
479
|
+
throw new TypeError("mcp.tool(name): name is required");
|
|
480
|
+
}
|
|
481
|
+
return withMcpGates(name, []);
|
|
482
|
+
},
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
/** @internal Attach a resolved gate list onto a fresh {@link McpToolTrigger}. */
|
|
486
|
+
function withMcpGates(name: string, gates: readonly GateRef[]): McpToolTrigger {
|
|
487
|
+
return {
|
|
488
|
+
kind: "mcp",
|
|
489
|
+
name,
|
|
490
|
+
gates,
|
|
491
|
+
gate: createGateAttach((next) => withMcpGates(name, next), gates),
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
414
495
|
/**
|
|
415
496
|
* Normalize anything accepted by {@link on} into a {@link Trigger}.
|
|
416
497
|
*
|
|
@@ -427,7 +508,8 @@ export function normalizeTrigger(value: Trigger | SignalSource): Trigger {
|
|
|
427
508
|
kind === "every" ||
|
|
428
509
|
kind === "signal" ||
|
|
429
510
|
kind === "cdc" ||
|
|
430
|
-
kind === "internal"
|
|
511
|
+
kind === "internal" ||
|
|
512
|
+
kind === "mcp"
|
|
431
513
|
) {
|
|
432
514
|
return value as Trigger;
|
|
433
515
|
}
|
package/src/manifest/diff.ts
CHANGED
|
@@ -29,6 +29,7 @@ import type {
|
|
|
29
29
|
Signal,
|
|
30
30
|
Slo,
|
|
31
31
|
Store,
|
|
32
|
+
StoreLive,
|
|
32
33
|
Table,
|
|
33
34
|
Tenancy,
|
|
34
35
|
Trigger,
|
|
@@ -102,6 +103,7 @@ export function diffManifest(before: Manifest, after: Manifest): ManifestDiffRes
|
|
|
102
103
|
diffAi(before.ai, after.ai, changes);
|
|
103
104
|
diffDrivers(before.drivers, after.drivers, changes);
|
|
104
105
|
diffTenancy(before.tenancy, after.tenancy, changes);
|
|
106
|
+
diffStoreLive(before.store, after.store, changes);
|
|
105
107
|
diffI18n(before.i18n, after.i18n, changes);
|
|
106
108
|
diffTopology(before.topology, after.topology, changes);
|
|
107
109
|
diffImages(before.images, after.images, changes);
|
|
@@ -849,6 +851,18 @@ function diffTable(before: Table, after: Table, path: string, out: ManifestChang
|
|
|
849
851
|
),
|
|
850
852
|
);
|
|
851
853
|
}
|
|
854
|
+
if (before.live !== after.live && (before.live !== undefined || after.live !== undefined)) {
|
|
855
|
+
out.push(
|
|
856
|
+
change(
|
|
857
|
+
`${path}/live`,
|
|
858
|
+
"effect-widening",
|
|
859
|
+
kindOf(before.live, after.live),
|
|
860
|
+
before.live,
|
|
861
|
+
after.live,
|
|
862
|
+
`live ${String(before.live)} → ${String(after.live)}`,
|
|
863
|
+
),
|
|
864
|
+
);
|
|
865
|
+
}
|
|
852
866
|
}
|
|
853
867
|
|
|
854
868
|
function diffClock(before: Clock, after: Clock, path: string, out: ManifestChange[]): void {
|
|
@@ -1289,6 +1303,29 @@ function diffTenancy(
|
|
|
1289
1303
|
}
|
|
1290
1304
|
}
|
|
1291
1305
|
|
|
1306
|
+
function diffStoreLive(
|
|
1307
|
+
before: StoreLive | undefined,
|
|
1308
|
+
after: StoreLive | undefined,
|
|
1309
|
+
out: ManifestChange[],
|
|
1310
|
+
): void {
|
|
1311
|
+
if (deepEqual(before, after)) return;
|
|
1312
|
+
const beforeEnabled = before?.live === true;
|
|
1313
|
+
const afterEnabled = after?.live === true;
|
|
1314
|
+
if (!beforeEnabled && afterEnabled) {
|
|
1315
|
+
// New default live surfaces under the flag are synthesized /live routes
|
|
1316
|
+
// over SQL tables — effect-widening (behavioral, but not a contract break).
|
|
1317
|
+
out.push(
|
|
1318
|
+
change("/store/live", "effect-widening", "changed", undefined, true, "store.live enabled"),
|
|
1319
|
+
);
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
if (beforeEnabled && !afterEnabled) {
|
|
1323
|
+
out.push(
|
|
1324
|
+
change("/store/live", "effect-widening", "changed", true, undefined, "store.live disabled"),
|
|
1325
|
+
);
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1292
1329
|
function diffI18n(before: I18n | undefined, after: I18n | undefined, out: ManifestChange[]): void {
|
|
1293
1330
|
if (deepEqual(before, after)) return;
|
|
1294
1331
|
if (before === undefined && after !== undefined) {
|