@cosmicdrift/kumiko-framework 0.339.0 → 0.341.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/api/index.d.ts +2 -1
- package/dist/api/index.js +1 -1
- package/dist/api/route-registrars.d.ts +4 -2
- package/dist/api/route-registrars.js +9 -1
- package/dist/api/server.d.ts +2 -1
- package/dist/api/server.js +43 -5
- package/dist/bun-db/index.d.ts +2 -2
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +25 -1
- package/dist/bun-db/query.js +136 -15
- package/dist/db/queries/shadow-swap.d.ts +2 -1
- package/dist/db/queries/shadow-swap.js +70 -7
- package/dist/db/query-api.d.ts +1 -1
- package/dist/db/query.d.ts +1 -1
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +48 -0
- package/dist/engine/define-handler.js +1 -0
- package/dist/engine/feature-ast/extractors/handlers.d.ts +2 -1
- package/dist/engine/feature-ast/extractors/handlers.js +11 -1
- package/dist/engine/feature-ast/extractors/hooks.d.ts +2 -1
- package/dist/engine/feature-ast/extractors/hooks.js +14 -0
- package/dist/engine/feature-ast/extractors/index.d.ts +1 -1
- package/dist/engine/feature-ast/extractors/index.js +1 -1
- package/dist/engine/feature-ast/patch.js +1 -0
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +20 -0
- package/dist/engine/feature-ast/pattern-change-schema.js +10 -0
- package/dist/engine/feature-ast/patterns.d.ts +2 -1
- package/dist/engine/feature-ast/render.js +3 -0
- package/dist/engine/feature-entity-handlers.d.ts +2 -1
- package/dist/engine/feature-entity-handlers.js +4 -0
- package/dist/engine/feature-ui-extensions.js +9 -0
- package/dist/engine/index.d.ts +1 -1
- package/dist/engine/membership-roles.js +5 -1
- package/dist/engine/pattern-library/mixed-schemas.js +5 -0
- package/dist/engine/registry-state.js +38 -5
- package/dist/engine/registry-validate.js +2 -0
- package/dist/engine/steps/unsafe-projection-upsert.js +1 -1
- package/dist/engine/tier-resolver-extension.d.ts +2 -0
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/entrypoint/index.js +25 -6
- package/dist/errors/classes.d.ts +5 -0
- package/dist/errors/classes.js +13 -0
- package/dist/errors/index.d.ts +1 -1
- package/dist/errors/index.js +1 -1
- package/dist/files/file-routes.js +7 -2
- package/dist/files/index.d.ts +1 -0
- package/dist/files/index.js +1 -0
- package/dist/files/provider-resolver.d.ts +2 -0
- package/dist/files/provider-resolver.js +16 -1
- package/dist/files/resolve-content-type.d.ts +11 -0
- package/dist/files/resolve-content-type.js +71 -0
- package/dist/files/types.d.ts +1 -1
- package/dist/files/types.js +5 -1
- package/dist/jobs/job-runner.js +1 -1
- package/dist/pipeline/dispatch-shared.d.ts +4 -1
- package/dist/pipeline/dispatch-shared.js +70 -9
- package/dist/pipeline/dispatch-write.js +35 -10
- package/dist/pipeline/dispatcher.d.ts +2 -0
- package/dist/pipeline/dispatcher.js +17 -1
- package/dist/pipeline/projection-rebuild.js +1 -1
- package/dist/rate-limit/bucket.d.ts +4 -0
- package/dist/rate-limit/bucket.js +25 -0
- package/dist/rate-limit/index.d.ts +2 -2
- package/dist/rate-limit/index.js +2 -2
- package/dist/rate-limit/middleware.d.ts +9 -0
- package/dist/rate-limit/middleware.js +33 -2
- package/dist/rate-limit/resolver.js +2 -1
- package/dist/redis/cache-sync-bus.d.ts +24 -0
- package/dist/redis/cache-sync-bus.js +152 -0
- package/dist/redis/cache-sync-topics.d.ts +17 -0
- package/dist/redis/cache-sync-topics.js +22 -0
- package/dist/redis/index.d.ts +4 -0
- package/dist/redis/index.js +2 -0
- package/dist/redis/pubsub-signal.d.ts +1 -0
- package/dist/redis/pubsub-signal.js +28 -0
- package/dist/stack/redis.d.ts +6 -1
- package/dist/stack/redis.js +7 -2
- package/dist/stack/test-stack.d.ts +15 -0
- package/dist/stack/test-stack.js +21 -1
- package/package.json +4 -4
- package/src/changes.json +54 -0
package/dist/api/index.d.ts
CHANGED
|
@@ -21,7 +21,8 @@ export type { RedisSseBroker, RedisSseBrokerOptions } from "./redis-sse-broker.j
|
|
|
21
21
|
export { createDefaultSseBroker, createRedisSseBroker, isRedisSseBroker, } from "./redis-sse-broker.js";
|
|
22
22
|
export { type RequestContextData, requestContext, runAsDirectCallEntry, } from "./request-context.js";
|
|
23
23
|
export { buildRequestContextDataFromRequest, requestIdMiddleware, } from "./request-id-middleware.js";
|
|
24
|
-
export {
|
|
24
|
+
export type { MetricsRouteOptions } from "./route-registrars.js";
|
|
25
|
+
export { createMetricsApp, DEFAULT_MAX_REQUEST_BYTES, isPrometheusMeter, } from "./route-registrars.js";
|
|
25
26
|
export { createApiRoutes } from "./routes.js";
|
|
26
27
|
export type { KumikoServer, ServerOptions } from "./server.js";
|
|
27
28
|
export { buildServer, makeDispatchSystemQuery, makeDispatchSystemWrite } from "./server.js";
|
package/dist/api/index.js
CHANGED
|
@@ -12,7 +12,7 @@ export { isSafeLandingPath } from "./post-auth-landing.js";
|
|
|
12
12
|
export { createDefaultSseBroker, createRedisSseBroker, isRedisSseBroker, } from "./redis-sse-broker.js";
|
|
13
13
|
export { requestContext, runAsDirectCallEntry, } from "./request-context.js";
|
|
14
14
|
export { buildRequestContextDataFromRequest, requestIdMiddleware, } from "./request-id-middleware.js";
|
|
15
|
-
export { DEFAULT_MAX_REQUEST_BYTES } from "./route-registrars.js";
|
|
15
|
+
export { createMetricsApp, DEFAULT_MAX_REQUEST_BYTES, isPrometheusMeter, } from "./route-registrars.js";
|
|
16
16
|
export { createApiRoutes } from "./routes.js";
|
|
17
17
|
export { buildServer, makeDispatchSystemQuery, makeDispatchSystemWrite } from "./server.js";
|
|
18
18
|
export { createSseBroker } from "./sse-broker.js";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { Hono } from "hono";
|
|
2
2
|
import type { DbConnection } from "../db/connection.js";
|
|
3
3
|
import type { Lifecycle } from "../lifecycle/index.js";
|
|
4
|
-
import type { Meter } from "../observability/index.js";
|
|
4
|
+
import type { Meter, PrometheusMeter } from "../observability/index.js";
|
|
5
5
|
import type { EventConsumer } from "../pipeline/event-dispatcher.js";
|
|
6
6
|
export declare const DEFAULT_MAX_REQUEST_BYTES = 1048576;
|
|
7
7
|
export declare function registerBodyLimit(app: Hono, maxBytes: number): void;
|
|
@@ -9,7 +9,9 @@ export type MetricsRouteOptions = {
|
|
|
9
9
|
readonly token?: string;
|
|
10
10
|
readonly path?: string;
|
|
11
11
|
};
|
|
12
|
+
export declare function isPrometheusMeter(m: Meter): m is PrometheusMeter;
|
|
12
13
|
export declare function registerMetricsRoute(app: Hono, meter: Meter, options: MetricsRouteOptions): void;
|
|
14
|
+
export declare function createMetricsApp(meter: Meter, options: MetricsRouteOptions): Hono;
|
|
13
15
|
export declare function registerVersionRoute(app: Hono): void;
|
|
14
16
|
export type HealthRoutesOptions = {
|
|
15
17
|
readonly lifecycle?: Lifecycle;
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// branches. Every registrar takes the Hono app + whatever state it
|
|
4
4
|
// actually reads — no monolithic `options` parameter, because these
|
|
5
5
|
// helpers exist to make each step's dependencies visible at the call-site.
|
|
6
|
+
import { Hono } from "hono";
|
|
6
7
|
import { bodyLimit } from "hono/body-limit";
|
|
7
8
|
import { serializeOpenMetrics } from "../observability/index.js";
|
|
8
9
|
import { BODY_LIMIT_OPT_OUT_PATHS, Routes } from "./api-constants.js";
|
|
@@ -45,7 +46,7 @@ function constantTimeEqual(a, b) {
|
|
|
45
46
|
// Type-guard: duck-types the meter on `snapshot()`. An alternative
|
|
46
47
|
// Prometheus-compatible meter (future OTLP bridge) works without an
|
|
47
48
|
// explicit union — if it exposes `snapshot()` it's serialisable.
|
|
48
|
-
function isPrometheusMeter(m) {
|
|
49
|
+
export function isPrometheusMeter(m) {
|
|
49
50
|
return typeof m.snapshot === "function"; // @cast-boundary schema-walk
|
|
50
51
|
}
|
|
51
52
|
// Mount `/metrics` (or the caller-supplied path). Takes just the Meter —
|
|
@@ -73,6 +74,13 @@ export function registerMetricsRoute(app, meter, options) {
|
|
|
73
74
|
return c.body(body);
|
|
74
75
|
});
|
|
75
76
|
}
|
|
77
|
+
// Standalone fetch-handler serving only the scrape route — for HTTP-less
|
|
78
|
+
// processes (worker pods) that expose /metrics on their own port.
|
|
79
|
+
export function createMetricsApp(meter, options) {
|
|
80
|
+
const app = new Hono();
|
|
81
|
+
registerMetricsRoute(app, meter, options);
|
|
82
|
+
return app;
|
|
83
|
+
}
|
|
76
84
|
// --- /version ---------------------------------------------------------------
|
|
77
85
|
// Anonymous endpoint that returns build-identity. Used by ops tooling
|
|
78
86
|
// (prod-version.sh) and the Telegram deploy notification so nobody needs
|
package/dist/api/server.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { CacheSyncBus } from "@cosmicdrift/kumiko-types/cache-sync-types";
|
|
1
2
|
import { Hono } from "hono";
|
|
2
3
|
import type { PgClient } from "../db/connection.js";
|
|
3
4
|
import { type AppContext, type Registry, type RunIn, type WriteResult } from "../engine/types/index.js";
|
|
@@ -75,7 +76,7 @@ export type KumikoServer = {
|
|
|
75
76
|
lifecycle?: Lifecycle;
|
|
76
77
|
context: AppContext;
|
|
77
78
|
};
|
|
78
|
-
export declare function withFileProviderResolver(registry: Registry, context: AppContext): AppContext;
|
|
79
|
+
export declare function withFileProviderResolver(registry: Registry, context: AppContext, cacheSync?: CacheSyncBus): AppContext;
|
|
79
80
|
export declare function buildServer(options: ServerOptions): KumikoServer;
|
|
80
81
|
export declare function makeDispatchSystemWrite(dispatcher: Dispatcher): (args: SystemDispatchArgs) => Promise<WriteResult>;
|
|
81
82
|
export declare function makeDispatchSystemQuery(dispatcher: Dispatcher): (args: SystemDispatchArgs) => Promise<unknown>;
|
package/dist/api/server.js
CHANGED
|
@@ -19,7 +19,8 @@ import { createEventDispatcher } from "../pipeline/event-dispatcher.js";
|
|
|
19
19
|
import { createLifecycleHooks } from "../pipeline/lifecycle-pipeline.js";
|
|
20
20
|
import { createMultiStreamApplyContext } from "../pipeline/multi-stream-apply-context.js";
|
|
21
21
|
import { createAccessInvalidationEventConsumer, createJobTriggerEventConsumer, createSearchEventConsumer, createSseBroadcastEventConsumer, } from "../pipeline/system-hooks.js";
|
|
22
|
-
import { authEndpointRateLimit, createRateLimitResolver, globalIpRateLimit, } from "../rate-limit/index.js";
|
|
22
|
+
import { authEndpointRateLimit, createPayloadDigest, createRateLimitResolver, globalIpRateLimit, httpRouteRateLimit, } from "../rate-limit/index.js";
|
|
23
|
+
import { resolveCacheSyncBus } from "../redis/cache-sync-bus.js";
|
|
23
24
|
import { deriveSearchAdapterConfig } from "../search/derive-search-adapter-config.js";
|
|
24
25
|
import { assertUnreachable, generateId } from "../utils/index.js";
|
|
25
26
|
import { collectAnonymousLiveEntities } from "./anonymous-live-entities.js";
|
|
@@ -47,7 +48,7 @@ import { buildWebSocketSessionRevalidator, createWebSocketConnectionLimiter, has
|
|
|
47
48
|
// per-tenant provider cache). Mirrors buildServer's own resolution exactly —
|
|
48
49
|
// including NOT inventing a resolver when no `file-provider-*` plugin is
|
|
49
50
|
// mounted, which is what keeps buildServer's boot-guard below able to fire.
|
|
50
|
-
export function withFileProviderResolver(registry, context) {
|
|
51
|
+
export function withFileProviderResolver(registry, context, cacheSync) {
|
|
51
52
|
if (context._fileProviderResolver !== undefined)
|
|
52
53
|
return context;
|
|
53
54
|
if (registry.getExtensionUsages(EXT_FILE_PROVIDER).length === 0)
|
|
@@ -59,6 +60,7 @@ export function withFileProviderResolver(registry, context) {
|
|
|
59
60
|
_configAccessorFactory: context._configAccessorFactory,
|
|
60
61
|
secrets: context.secrets,
|
|
61
62
|
db: context.db,
|
|
63
|
+
...(cacheSync ? { cacheSync } : {}),
|
|
62
64
|
}),
|
|
63
65
|
};
|
|
64
66
|
}
|
|
@@ -127,6 +129,10 @@ export function buildServer(options) {
|
|
|
127
129
|
sseBroker = defaults.sseBroker;
|
|
128
130
|
ownedRedisSseBroker = defaults.ownedRedisSseBroker;
|
|
129
131
|
}
|
|
132
|
+
// Same decision as the SSE broker: an explicit dispatcherOptions value
|
|
133
|
+
// (incl. null = opt out) wins, otherwise REDIS_URL turns on cross-replica
|
|
134
|
+
// cache invalidation (no REDIS_URL = process-local bus).
|
|
135
|
+
const { bus: cacheSync, owned: ownedCacheSync } = resolveCacheSyncBus(options.dispatcherOptions?.cacheSync);
|
|
130
136
|
// Resolve the per-process instance identifier. Prefer explicit
|
|
131
137
|
// ServerOptions.instanceId (tests, deliberate wiring), fall back to the
|
|
132
138
|
// deploy-env variable, finally a boot-time UUID. Validator rejects the
|
|
@@ -184,7 +190,7 @@ export function buildServer(options) {
|
|
|
184
190
|
// resolver once (when a provider plugin is mounted) — the dispatcher uses it
|
|
185
191
|
// to materialise `ctx.files`, the upload routes + MSP-applies share it. The
|
|
186
192
|
// resolver reads config + the s3.secretAccessKey secret under SYSTEM identity.
|
|
187
|
-
const contextWithFiles = withFileProviderResolver(options.registry, options.context);
|
|
193
|
+
const contextWithFiles = withFileProviderResolver(options.registry, options.context, cacheSync);
|
|
188
194
|
const fileProviderResolver = contextWithFiles._fileProviderResolver;
|
|
189
195
|
// Auto-wire the rate-limit resolver, but ONLY when at least one
|
|
190
196
|
// handler actually declared a rateLimit option. Apps that don't use
|
|
@@ -197,13 +203,22 @@ export function buildServer(options) {
|
|
|
197
203
|
// both share the same instance to avoid duplicate Lua-script registration.
|
|
198
204
|
const wantsL3 = options.registry.hasRateLimitedHandler();
|
|
199
205
|
const wantsL1L2 = options.rateLimit?.global !== undefined || options.rateLimit?.auth !== undefined;
|
|
200
|
-
const
|
|
206
|
+
const wantsHttpRouteLimit = [...options.registry.features.values()].some((feature) => Object.values(feature.httpRoutes).some((route) => route.rateLimit !== undefined));
|
|
207
|
+
const wantsResolver = wantsL3 || wantsL1L2 || wantsHttpRouteLimit;
|
|
208
|
+
// Master for the payload-bucket HMAC: the current signing secret (the
|
|
209
|
+
// keyring's signKid for rotation setups). buildServer always has one, so
|
|
210
|
+
// the digest is available whenever a handler declares additionalRateLimits.
|
|
211
|
+
const payloadDigest = createPayloadDigest(typeof options.jwtSecret === "string"
|
|
212
|
+
? options.jwtSecret
|
|
213
|
+
: (options.jwtSecret.keys[options.jwtSecret.signKid] ?? ""));
|
|
201
214
|
const rateLimitResolver = options.context.rateLimit ??
|
|
202
215
|
(wrappedRedis && wantsResolver ? createRateLimitResolver({ redis: wrappedRedis }) : undefined);
|
|
203
216
|
const contextWithObservability = {
|
|
204
217
|
...contextWithFiles,
|
|
205
218
|
...(wrappedRedis ? { redis: wrappedRedis } : {}),
|
|
206
219
|
...(rateLimitResolver ? { rateLimit: rateLimitResolver } : {}),
|
|
220
|
+
_rateLimitPayloadDigest: payloadDigest,
|
|
221
|
+
...(cacheSync ? { cacheSync } : {}),
|
|
207
222
|
// Propagate the feature-toggle resolver to the context so the event-
|
|
208
223
|
// dispatcher (and any future context-reading consumer) sees the same
|
|
209
224
|
// source as the command dispatcher's handler-gate. Options take
|
|
@@ -220,6 +235,7 @@ export function buildServer(options) {
|
|
|
220
235
|
...options.dispatcherOptions,
|
|
221
236
|
lifecycle,
|
|
222
237
|
sseBroker,
|
|
238
|
+
cacheSync: cacheSync ?? null,
|
|
223
239
|
...(options.auth ? { membershipQuery: options.auth.membershipQuery } : {}),
|
|
224
240
|
});
|
|
225
241
|
// Async event-dispatcher — the replacement for the old transactional
|
|
@@ -379,6 +395,12 @@ export function buildServer(options) {
|
|
|
379
395
|
await broker.close();
|
|
380
396
|
});
|
|
381
397
|
}
|
|
398
|
+
if (options.lifecycle && ownedCacheSync) {
|
|
399
|
+
const bus = ownedCacheSync;
|
|
400
|
+
options.lifecycle.registerShutdownHook("cacheSync", async () => {
|
|
401
|
+
await bus.close();
|
|
402
|
+
});
|
|
403
|
+
}
|
|
382
404
|
const app = new Hono();
|
|
383
405
|
// Sub-apps mounted via app.route() without their own handler inherit this one.
|
|
384
406
|
app.onError(handleUncaughtRouteError);
|
|
@@ -657,7 +679,23 @@ export function buildServer(options) {
|
|
|
657
679
|
systemQuery: makeSystemQuery(c, dispatcher, clientIpResolver),
|
|
658
680
|
clientIp: clientIpResolver.resolve(clientIpSourceFromHonoContext(c)),
|
|
659
681
|
});
|
|
660
|
-
|
|
682
|
+
// Without a resolver (no Redis in the context — dev/test stacks) the
|
|
683
|
+
// declared limit is skipped rather than failing the boot of every app
|
|
684
|
+
// that mounts a bundled feature whose routes carry a default limit.
|
|
685
|
+
const routeLimit = route.rateLimit && rateLimitResolver
|
|
686
|
+
? [
|
|
687
|
+
httpRouteRateLimit({
|
|
688
|
+
resolver: rateLimitResolver,
|
|
689
|
+
rateLimit: route.rateLimit,
|
|
690
|
+
routeKey: `${route.method} ${route.path}`,
|
|
691
|
+
clientIpResolver,
|
|
692
|
+
}),
|
|
693
|
+
]
|
|
694
|
+
: [];
|
|
695
|
+
mountHonoRoute(app, route.method, route.path, honoHandler, [
|
|
696
|
+
...routeLimit,
|
|
697
|
+
...(route.anonymous ? [] : sessionOnlyHttpRouteGuards),
|
|
698
|
+
]);
|
|
661
699
|
}
|
|
662
700
|
}
|
|
663
701
|
// r.webSocketRoute — GET routes under /api/ws/*, so the jwt/pat/rate-limit
|
package/dist/bun-db/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export type { BunDbConnection, BunDbConnectionOptions, BunDbRunner, BunDbTx, PgListenClient, } from "./connection.js";
|
|
2
2
|
export { bunDbConnectionOptionsFromEnv, createBunDbConnection } from "./connection.js";
|
|
3
|
-
export type { SelectOptions, TableInfo, WhereObject, WhereOperator, WhereValue } from "./query.js";
|
|
4
|
-
export { aggregateWhere, asEntityTableMeta, asRawClient, countWhere, type DeleteManyBatchedOptions, type DeleteManyBatchedResult, deleteMany, deleteManyBatched, extractTableInfo, fetchOne, type IncrementCounterOptions, incrementCounter, insertMany, insertOne, isTimestamptzType, requireEntityTableMeta, runInSavepoint, runInSavepointIfSupported, selectMany, transaction, type UpsertOnConflictOptions, unsafeReadRetrying, updateMany, upsertByPk, upsertOnConflict, } from "./query.js";
|
|
3
|
+
export type { InnerJoinRow, InnerJoinSpec, InsertOnConflictDoNothingOptions, JoinColumnPair, JsonTextMatch, SelectOptions, TableInfo, WhereObject, WhereOperator, WhereValue, } from "./query.js";
|
|
4
|
+
export { aggregateWhere, asEntityTableMeta, asRawClient, countWhere, type DeleteManyBatchedOptions, type DeleteManyBatchedResult, deleteMany, deleteManyBatched, extractTableInfo, fetchOne, type IncrementCounterOptions, incrementCounter, insertMany, insertOnConflictDoNothing, insertOne, isTimestamptzType, requireEntityTableMeta, runInSavepoint, runInSavepointIfSupported, selectInnerJoin, selectMany, transaction, type UpsertOnConflictOptions, unsafeReadRetrying, updateMany, upsertByPk, upsertOnConflict, } from "./query.js";
|
package/dist/bun-db/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// bun-db: Bun.sql-basierte DB-API ohne drizzle.
|
|
2
2
|
// Production-Pfad nach drizzle-removal.
|
|
3
3
|
export { bunDbConnectionOptionsFromEnv, createBunDbConnection } from "./connection.js";
|
|
4
|
-
export { aggregateWhere, asEntityTableMeta, asRawClient, countWhere, deleteMany, deleteManyBatched, extractTableInfo, fetchOne, incrementCounter, insertMany, insertOne, isTimestamptzType, requireEntityTableMeta, runInSavepoint, runInSavepointIfSupported, selectMany, transaction, unsafeReadRetrying, updateMany, upsertByPk, upsertOnConflict, } from "./query.js";
|
|
4
|
+
export { aggregateWhere, asEntityTableMeta, asRawClient, countWhere, deleteMany, deleteManyBatched, extractTableInfo, fetchOne, incrementCounter, insertMany, insertOnConflictDoNothing, insertOne, isTimestamptzType, requireEntityTableMeta, runInSavepoint, runInSavepointIfSupported, selectInnerJoin, selectMany, transaction, unsafeReadRetrying, updateMany, upsertByPk, upsertOnConflict, } from "./query.js";
|
package/dist/bun-db/query.d.ts
CHANGED
|
@@ -14,7 +14,7 @@ export declare function asRawClient(db: unknown): RawClient;
|
|
|
14
14
|
export declare function runInSavepoint<T>(tx: unknown, fn: (sp: unknown) => Promise<T>): Promise<T>;
|
|
15
15
|
export declare function runInSavepointIfSupported<T>(db: unknown, fn: (sp: DbRunner) => Promise<T>): Promise<T>;
|
|
16
16
|
export type AnyDb = BunDbRunner | unknown;
|
|
17
|
-
export type { OrderByClause, SelectOptions, WhereObject, WhereOperator, WhereValue, } from "@cosmicdrift/kumiko-types/where-clause-types";
|
|
17
|
+
export type { JsonTextMatch, OrderByClause, SelectOptions, WhereObject, WhereOperator, WhereValue, } from "@cosmicdrift/kumiko-types/where-clause-types";
|
|
18
18
|
type TableLike = EntityTableMeta | any;
|
|
19
19
|
type WritableTable = EntityTableMeta & NotExecutorOnly;
|
|
20
20
|
export type TableInfo = {
|
|
@@ -32,6 +32,26 @@ export declare function describeClosedConnectionFailure(raw: unknown, err: unkno
|
|
|
32
32
|
export declare function unsafeReadRetrying<TRow>(db: AnyDb, sqlText: string, params: readonly unknown[]): Promise<readonly TRow[]>;
|
|
33
33
|
export declare function selectMany<TRow = any>(db: AnyDb, table: TableLike, where?: WhereObject, options?: SelectOptions): Promise<readonly TRow[]>;
|
|
34
34
|
export declare function fetchOne<TRow = any>(db: AnyDb, table: TableLike, where: WhereObject): Promise<TRow | undefined>;
|
|
35
|
+
export type JoinColumnPair = {
|
|
36
|
+
readonly left: string;
|
|
37
|
+
readonly right: string;
|
|
38
|
+
};
|
|
39
|
+
export type InnerJoinSpec = {
|
|
40
|
+
readonly left: TableLike;
|
|
41
|
+
readonly right: TableLike;
|
|
42
|
+
readonly on: readonly [JoinColumnPair, ...JoinColumnPair[]];
|
|
43
|
+
readonly leftWhere?: WhereObject;
|
|
44
|
+
readonly rightWhere?: WhereObject;
|
|
45
|
+
/** Fields to return per side; default: all columns of `left`, none of `right`. */
|
|
46
|
+
readonly leftFields?: readonly string[];
|
|
47
|
+
readonly rightFields?: readonly string[];
|
|
48
|
+
readonly limit?: number;
|
|
49
|
+
};
|
|
50
|
+
export type InnerJoinRow<TLeft, TRight> = {
|
|
51
|
+
readonly left: TLeft;
|
|
52
|
+
readonly right: TRight;
|
|
53
|
+
};
|
|
54
|
+
export declare function selectInnerJoin<TLeft = Record<string, unknown>, TRight = Record<string, unknown>>(db: AnyDb, spec: InnerJoinSpec): Promise<readonly InnerJoinRow<TLeft, TRight>[]>;
|
|
35
55
|
export declare function insertMany<TRow = any>(db: AnyDb, table: WritableTable, rows: ReadonlyArray<Record<string, unknown>>): Promise<readonly TRow[]>;
|
|
36
56
|
export declare function insertOne<TRow = any>(db: AnyDb, table: WritableTable, values: Record<string, unknown>): Promise<TRow | undefined>;
|
|
37
57
|
export declare function updateMany<TRow = any>(db: AnyDb, table: WritableTable, set: Record<string, unknown>, where: WhereObject): Promise<readonly TRow[]>;
|
|
@@ -57,6 +77,10 @@ export declare function isTimestamptzType(pgType: string | undefined): boolean;
|
|
|
57
77
|
export declare function aggregateWhere(db: AnyDb, table: TableLike, spec: AggregateSpec, where?: WhereObject): Promise<readonly AggregateRow[]>;
|
|
58
78
|
export declare function upsertOnConflict<TRow = any>(db: AnyDb, table: WritableTable, values: Record<string, unknown>, options: UpsertOnConflictOptions): Promise<TRow | undefined>;
|
|
59
79
|
export declare function upsertByPk<TRow = any>(db: AnyDb, table: WritableTable, values: Record<string, unknown>, updateOnConflict?: Record<string, unknown>): Promise<TRow | undefined>;
|
|
80
|
+
export type InsertOnConflictDoNothingOptions = {
|
|
81
|
+
readonly conflictKeys?: readonly string[];
|
|
82
|
+
};
|
|
83
|
+
export declare function insertOnConflictDoNothing<TRow = any>(db: AnyDb, table: WritableTable, values: Record<string, unknown>, options?: InsertOnConflictDoNothingOptions): Promise<TRow | undefined>;
|
|
60
84
|
export type IncrementCounterOptions = {
|
|
61
85
|
readonly conflictKeys?: readonly string[];
|
|
62
86
|
/** Extra SET clauses on conflict (e.g. lastUpdatedAt: sql\`NOW()\`). */
|
package/dist/bun-db/query.js
CHANGED
|
@@ -179,7 +179,7 @@ function assertNotTenantScoped(db, fnName) {
|
|
|
179
179
|
"explicit cross-tenant op, or use a scoped TenantDb method.");
|
|
180
180
|
}
|
|
181
181
|
}
|
|
182
|
-
const WHERE_OPERATOR_KEYS = ["gt", "gte", "lt", "lte", "ne", "in", "like"];
|
|
182
|
+
const WHERE_OPERATOR_KEYS = ["gt", "gte", "lt", "lte", "ne", "in", "like", "jsonText"];
|
|
183
183
|
const _whereOperatorKeysExhaustive = true;
|
|
184
184
|
void _whereOperatorKeysExhaustive;
|
|
185
185
|
function isWhereOperator(v) {
|
|
@@ -483,10 +483,13 @@ function prepareValue(value, pgType) {
|
|
|
483
483
|
}
|
|
484
484
|
return { kind: "param", sql: "", bound: value };
|
|
485
485
|
}
|
|
486
|
-
function buildWhereClause(info, where, startIndex) {
|
|
486
|
+
function buildWhereClause(info, where, startIndex, qualifier) {
|
|
487
487
|
const conditions = [];
|
|
488
488
|
const values = [];
|
|
489
489
|
let idx = startIndex;
|
|
490
|
+
// Single place that renders a column reference, so a join qualifier reaches
|
|
491
|
+
// every arm (blind index, jsonb containment, IS NULL, jsonText) alike.
|
|
492
|
+
const ref = (column) => qualifier === undefined ? quoteIdent(column) : `${quoteIdent(qualifier)}.${quoteIdent(column)}`;
|
|
490
493
|
// multiSelect (and any other jsonb-array-of-scalars column) stores an
|
|
491
494
|
// array — a filter value is one option, not the whole array, so eq/ne/in
|
|
492
495
|
// must check array containment (`@>`) instead of scalar `=`/`<>`/`IN`
|
|
@@ -501,7 +504,7 @@ function buildWhereClause(info, where, startIndex) {
|
|
|
501
504
|
// prepareJsonbValue never returns kind:"literal" (only prepareValue's
|
|
502
505
|
// isSqlExpression branch does) — narrow anyway, PreparedValue is a union.
|
|
503
506
|
if (p && p.kind === "param") {
|
|
504
|
-
parts.push(`${
|
|
507
|
+
parts.push(`${ref(col)} @> $${idx++}${p.sql}`);
|
|
505
508
|
values.push(p.bound);
|
|
506
509
|
}
|
|
507
510
|
}
|
|
@@ -514,7 +517,7 @@ function buildWhereClause(info, where, startIndex) {
|
|
|
514
517
|
const col = info.columnOf(field);
|
|
515
518
|
const pgType = info.pgTypeOf(col);
|
|
516
519
|
if (value === null) {
|
|
517
|
-
conditions.push(`${
|
|
520
|
+
conditions.push(`${ref(col)} IS NULL`);
|
|
518
521
|
}
|
|
519
522
|
else if (Array.isArray(value)) {
|
|
520
523
|
if (value.length === 0) {
|
|
@@ -535,12 +538,37 @@ function buildWhereClause(info, where, startIndex) {
|
|
|
535
538
|
values.push(p.bound);
|
|
536
539
|
}
|
|
537
540
|
}
|
|
538
|
-
conditions.push(`${
|
|
541
|
+
conditions.push(`${ref(col)} IN (${parts.join(", ")})`);
|
|
539
542
|
}
|
|
540
543
|
}
|
|
541
544
|
else if (isWhereOperator(value)) {
|
|
545
|
+
if (value.jsonText !== undefined) {
|
|
546
|
+
if (Object.keys(value).length > 1) {
|
|
547
|
+
throw new Error(`where: jsonText on column "${col}" cannot be combined with other operators`);
|
|
548
|
+
}
|
|
549
|
+
if (pgType !== "jsonb") {
|
|
550
|
+
throw new Error(`where: jsonText requires a jsonb column, "${col}" is ${pgType}`);
|
|
551
|
+
}
|
|
552
|
+
const matches = Array.isArray(value.jsonText) ? value.jsonText : [value.jsonText];
|
|
553
|
+
for (const match of matches) {
|
|
554
|
+
if (!Array.isArray(match.keys) ||
|
|
555
|
+
match.keys.length === 0 ||
|
|
556
|
+
!match.keys.every((k) => typeof k === "string") ||
|
|
557
|
+
typeof match.eq !== "string") {
|
|
558
|
+
throw new Error(`where: jsonText on column "${col}" needs non-empty string keys and a string eq`);
|
|
559
|
+
}
|
|
560
|
+
const lookups = match.keys.map((key) => {
|
|
561
|
+
values.push(key);
|
|
562
|
+
return `${ref(col)}->>$${idx++}::text`;
|
|
563
|
+
});
|
|
564
|
+
const lhs = lookups.length === 1 ? lookups[0] : `COALESCE(${lookups.join(", ")})`;
|
|
565
|
+
values.push(match.eq);
|
|
566
|
+
conditions.push(`(${lhs} = $${idx++})`);
|
|
567
|
+
}
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
542
570
|
if (value.ne === null && Object.keys(value).length === 1) {
|
|
543
|
-
conditions.push(`${
|
|
571
|
+
conditions.push(`${ref(col)} IS NOT NULL`);
|
|
544
572
|
continue;
|
|
545
573
|
}
|
|
546
574
|
const opMap = {
|
|
@@ -564,17 +592,17 @@ function buildWhereClause(info, where, startIndex) {
|
|
|
564
592
|
if (opKey === "ne" && pgType === "jsonb" && isJsonbScalar(opVal)) {
|
|
565
593
|
const p = prepareJsonbValue([opVal]);
|
|
566
594
|
if (p && p.kind === "param") {
|
|
567
|
-
conditions.push(`(${
|
|
595
|
+
conditions.push(`(${ref(col)} IS NULL OR NOT (${ref(col)} @> $${idx++}${p.sql}))`);
|
|
568
596
|
values.push(p.bound);
|
|
569
597
|
continue;
|
|
570
598
|
}
|
|
571
599
|
}
|
|
572
600
|
const p = prepareValue(opVal, pgType);
|
|
573
601
|
if (p.kind === "literal") {
|
|
574
|
-
conditions.push(`${
|
|
602
|
+
conditions.push(`${ref(col)} ${opSym} ${p.literal}`);
|
|
575
603
|
}
|
|
576
604
|
else {
|
|
577
|
-
conditions.push(`${
|
|
605
|
+
conditions.push(`${ref(col)} ${opSym} $${idx++}${p.sql}`);
|
|
578
606
|
values.push(p.bound);
|
|
579
607
|
}
|
|
580
608
|
}
|
|
@@ -598,7 +626,7 @@ function buildWhereClause(info, where, startIndex) {
|
|
|
598
626
|
values.push(p.bound);
|
|
599
627
|
}
|
|
600
628
|
}
|
|
601
|
-
conditions.push(`${
|
|
629
|
+
conditions.push(`${ref(col)} IN (${parts.join(", ")})`);
|
|
602
630
|
}
|
|
603
631
|
}
|
|
604
632
|
}
|
|
@@ -608,7 +636,7 @@ function buildWhereClause(info, where, startIndex) {
|
|
|
608
636
|
else {
|
|
609
637
|
const p = prepareValue(value, pgType);
|
|
610
638
|
if (p.kind === "literal") {
|
|
611
|
-
conditions.push(`${
|
|
639
|
+
conditions.push(`${ref(col)} = ${p.literal}`);
|
|
612
640
|
}
|
|
613
641
|
else {
|
|
614
642
|
// Blind-index OR rewrite (#818): if the column has a bidx
|
|
@@ -623,11 +651,11 @@ function buildWhereClause(info, where, startIndex) {
|
|
|
623
651
|
typeof value === "string" &&
|
|
624
652
|
!col.endsWith("_bidx") &&
|
|
625
653
|
info.hasColumn(bidxCol)) {
|
|
626
|
-
conditions.push(`(${
|
|
654
|
+
conditions.push(`(${ref(col)} = $${idx++} OR ${ref(bidxCol)} = $${idx++})`);
|
|
627
655
|
values.push(p.bound, computeBlindIndex(bidxKey, value));
|
|
628
656
|
}
|
|
629
657
|
else {
|
|
630
|
-
conditions.push(`${
|
|
658
|
+
conditions.push(`${ref(col)} = $${idx++}${p.sql}`);
|
|
631
659
|
values.push(p.bound);
|
|
632
660
|
}
|
|
633
661
|
}
|
|
@@ -820,6 +848,76 @@ export async function fetchOne(db, table, where) {
|
|
|
820
848
|
const rows = await selectMany(db, table, where, { limit: 1 });
|
|
821
849
|
return rows[0];
|
|
822
850
|
}
|
|
851
|
+
const JOIN_LEFT_ALIAS = "l";
|
|
852
|
+
const JOIN_RIGHT_ALIAS = "r";
|
|
853
|
+
function requireJoinColumn(info, field, role) {
|
|
854
|
+
if (!info.hasColumn(field)) {
|
|
855
|
+
throw new Error(`selectInnerJoin: ${role} "${field}" is not a column of "${info.name}"`);
|
|
856
|
+
}
|
|
857
|
+
return info.columnOf(field);
|
|
858
|
+
}
|
|
859
|
+
export async function selectInnerJoin(db, spec) {
|
|
860
|
+
assertNotTenantScoped(db, "selectInnerJoin");
|
|
861
|
+
const leftInfo = extractTableInfo(spec.left);
|
|
862
|
+
const rightInfo = extractTableInfo(spec.right);
|
|
863
|
+
if (spec.limit !== undefined && (!Number.isInteger(spec.limit) || spec.limit < 0)) {
|
|
864
|
+
throw new Error(`selectInnerJoin: limit must be a non-negative integer, got ${spec.limit}`);
|
|
865
|
+
}
|
|
866
|
+
const leftColumns = (spec.leftFields ??
|
|
867
|
+
requireEntityTableMeta(spec.left, "selectInnerJoin").columns.map((c) => c.name)).map((field) => requireJoinColumn(leftInfo, field, "leftFields entry"));
|
|
868
|
+
const rightColumns = (spec.rightFields ?? []).map((field) => requireJoinColumn(rightInfo, field, "rightFields entry"));
|
|
869
|
+
// Positional aliases: a `<side>__<column>` scheme would hit Postgres' 63-byte
|
|
870
|
+
// identifier truncation for long column names.
|
|
871
|
+
const projected = [
|
|
872
|
+
...leftColumns.map((column) => ({ side: "left", column })),
|
|
873
|
+
...rightColumns.map((column) => ({ side: "right", column })),
|
|
874
|
+
];
|
|
875
|
+
if (projected.length === 0) {
|
|
876
|
+
throw new Error("selectInnerJoin: no columns selected");
|
|
877
|
+
}
|
|
878
|
+
const selectList = projected
|
|
879
|
+
.map(({ side, column }, index) => `${quoteIdent(side === "left" ? JOIN_LEFT_ALIAS : JOIN_RIGHT_ALIAS)}.${quoteIdent(column)} AS "c${index}"`)
|
|
880
|
+
.join(", ");
|
|
881
|
+
const onPairs = spec.on.map((pair) => {
|
|
882
|
+
const leftColumn = requireJoinColumn(leftInfo, pair.left, "on.left");
|
|
883
|
+
const rightColumn = requireJoinColumn(rightInfo, pair.right, "on.right");
|
|
884
|
+
const leftRef = `${quoteIdent(JOIN_LEFT_ALIAS)}.${quoteIdent(leftColumn)}`;
|
|
885
|
+
const rightRef = `${quoteIdent(JOIN_RIGHT_ALIAS)}.${quoteIdent(rightColumn)}`;
|
|
886
|
+
return leftInfo.pgTypeOf(leftColumn) === rightInfo.pgTypeOf(rightColumn)
|
|
887
|
+
? `${leftRef} = ${rightRef}`
|
|
888
|
+
: `${leftRef}::text = ${rightRef}::text`;
|
|
889
|
+
});
|
|
890
|
+
let sqlText = `SELECT ${selectList} FROM ${quoteIdent(leftInfo.name)} AS ${quoteIdent(JOIN_LEFT_ALIAS)} ` +
|
|
891
|
+
`INNER JOIN ${quoteIdent(rightInfo.name)} AS ${quoteIdent(JOIN_RIGHT_ALIAS)} ON ${onPairs.join(" AND ")}`;
|
|
892
|
+
const conditions = [];
|
|
893
|
+
const values = [];
|
|
894
|
+
if (spec.leftWhere && Object.keys(spec.leftWhere).length > 0) {
|
|
895
|
+
const w = buildWhereClause(leftInfo, spec.leftWhere, values.length + 1, JOIN_LEFT_ALIAS);
|
|
896
|
+
conditions.push(w.sqlText);
|
|
897
|
+
values.push(...w.values);
|
|
898
|
+
}
|
|
899
|
+
if (spec.rightWhere && Object.keys(spec.rightWhere).length > 0) {
|
|
900
|
+
const w = buildWhereClause(rightInfo, spec.rightWhere, values.length + 1, JOIN_RIGHT_ALIAS);
|
|
901
|
+
conditions.push(w.sqlText);
|
|
902
|
+
values.push(...w.values);
|
|
903
|
+
}
|
|
904
|
+
if (conditions.length > 0)
|
|
905
|
+
sqlText += ` WHERE ${conditions.join(" AND ")}`;
|
|
906
|
+
if (spec.limit !== undefined)
|
|
907
|
+
sqlText += ` LIMIT ${spec.limit}`;
|
|
908
|
+
const raw = (await unsafeReadRetrying(db, sqlText, values));
|
|
909
|
+
return raw.map((row) => {
|
|
910
|
+
const leftRow = {};
|
|
911
|
+
const rightRow = {};
|
|
912
|
+
projected.forEach(({ side, column }, index) => {
|
|
913
|
+
(side === "left" ? leftRow : rightRow)[column] = row[`c${index}`];
|
|
914
|
+
});
|
|
915
|
+
return {
|
|
916
|
+
left: coerceRow(leftRow, leftInfo),
|
|
917
|
+
right: coerceRow(rightRow, rightInfo),
|
|
918
|
+
};
|
|
919
|
+
});
|
|
920
|
+
}
|
|
823
921
|
// Bulk INSERT — same shape as insertOne but takes an array of rows and
|
|
824
922
|
// produces one multi-VALUES statement. Mirrors drizzle's
|
|
825
923
|
// `db.insert(t).values(rows[])`. Empty input is a no-op.
|
|
@@ -1064,15 +1162,20 @@ function assertValidAggregateLimits(spec) {
|
|
|
1064
1162
|
if (spec.orderByValue !== undefined && !AGGREGATE_ORDER_DIRECTIONS.includes(spec.orderByValue)) {
|
|
1065
1163
|
throw new Error(`aggregateWhere: unknown orderByValue "${String(spec.orderByValue)}"`);
|
|
1066
1164
|
}
|
|
1165
|
+
if (spec.orderByKeys !== undefined && !AGGREGATE_ORDER_DIRECTIONS.includes(spec.orderByKeys)) {
|
|
1166
|
+
throw new Error(`aggregateWhere: unknown orderByKeys "${String(spec.orderByKeys)}"`);
|
|
1167
|
+
}
|
|
1067
1168
|
}
|
|
1068
1169
|
function aggregateGroupOrderLimitSql(spec, dimensionCount) {
|
|
1069
1170
|
let sqlText = "";
|
|
1070
1171
|
if (dimensionCount > 0) {
|
|
1071
1172
|
const positions = Array.from({ length: dimensionCount }, (_, index) => String(index + 1));
|
|
1072
1173
|
sqlText += ` GROUP BY ${positions.join(", ")}`;
|
|
1174
|
+
const keyDirection = spec.orderByKeys === "desc" ? "DESC" : "ASC";
|
|
1175
|
+
const keyParts = positions.map((position) => `${position} ${keyDirection}`);
|
|
1073
1176
|
const orderParts = spec.orderByValue === undefined
|
|
1074
|
-
?
|
|
1075
|
-
: [`${dimensionCount + 1} ${spec.orderByValue === "desc" ? "DESC" : "ASC"}`, ...
|
|
1177
|
+
? keyParts
|
|
1178
|
+
: [`${dimensionCount + 1} ${spec.orderByValue === "desc" ? "DESC" : "ASC"}`, ...keyParts];
|
|
1076
1179
|
sqlText += ` ORDER BY ${orderParts.join(", ")}`;
|
|
1077
1180
|
}
|
|
1078
1181
|
if (spec.limit !== undefined)
|
|
@@ -1162,6 +1265,24 @@ export async function upsertByPk(db, table, values, updateOnConflict) {
|
|
|
1162
1265
|
});
|
|
1163
1266
|
}
|
|
1164
1267
|
// biome-ignore lint/suspicious/noExplicitAny: see selectMany default
|
|
1268
|
+
export async function insertOnConflictDoNothing(db, table, values, options) {
|
|
1269
|
+
assertNotTenantScoped(db, "insertOnConflictDoNothing");
|
|
1270
|
+
const info = extractTableInfo(table);
|
|
1271
|
+
const entries = insertEntries(info, values);
|
|
1272
|
+
const conflictCols = resolveConflictColumns(table, info, options?.conflictKeys);
|
|
1273
|
+
const { sqlPrefix, params } = buildInsertSql(info, entries);
|
|
1274
|
+
const conflictList = conflictCols.map((c) => quoteIdent(c)).join(", ");
|
|
1275
|
+
const sqlText = `${sqlPrefix} ON CONFLICT (${conflictList}) DO NOTHING RETURNING *`;
|
|
1276
|
+
const rows = (await unsafeLoggingClosedConnection(db, sqlText, params, {
|
|
1277
|
+
operation: "insertOnConflictDoNothing",
|
|
1278
|
+
table: info.name,
|
|
1279
|
+
}));
|
|
1280
|
+
const first = rows[0];
|
|
1281
|
+
if (!first)
|
|
1282
|
+
return undefined;
|
|
1283
|
+
return coerceRow(first, info);
|
|
1284
|
+
}
|
|
1285
|
+
// biome-ignore lint/suspicious/noExplicitAny: see selectMany default
|
|
1165
1286
|
export async function incrementCounter(db, table, values, increments, options = {}) {
|
|
1166
1287
|
assertNotTenantScoped(db, "incrementCounter");
|
|
1167
1288
|
const info = extractTableInfo(table);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ProjectionRowIdOf } from "@cosmicdrift/kumiko-types/projection";
|
|
1
2
|
import type { DbConnection, DbTx } from "../connection.js";
|
|
2
3
|
import type { EntityTableMeta } from "../entity-table-meta.js";
|
|
3
4
|
import { type AnyDb } from "../query.js";
|
|
@@ -8,7 +9,7 @@ export declare function assertLiveColumnsMatchMeta(db: DbConnection | DbTx, meta
|
|
|
8
9
|
export declare function assertLiveTableHasNoRowLevelSecurity(tx: AnyDb, tableName: string, projectionName?: string): Promise<void>;
|
|
9
10
|
export declare function buildShadowTable(tx: AnyDb, meta: EntityTableMeta): Promise<void>;
|
|
10
11
|
export declare function fenceLiveTable(tx: AnyDb, tableName: string, lockTimeoutMs: number): Promise<void>;
|
|
11
|
-
export declare function assertNoUnreachableLiveRows(tx: AnyDb, projectionName: string, tableName: string, aggregateTypes: readonly string[]): Promise<void>;
|
|
12
|
+
export declare function assertNoUnreachableLiveRows(tx: AnyDb, projectionName: string, tableName: string, aggregateTypes: readonly string[], derivedRowIds?: Readonly<Record<string, ProjectionRowIdOf>>): Promise<void>;
|
|
12
13
|
export declare function assertNoBlindIndexLoss(tx: AnyDb, tableName: string, meta: EntityTableMeta, projectionName: string): Promise<void>;
|
|
13
14
|
export type ColumnDriftResult = {
|
|
14
15
|
readonly rowCount: number;
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
// Boundary: the shadow table is rebuilt from EntityTableMeta, so any index NOT
|
|
18
18
|
// expressed in meta (hand-added in a migration) is not reconstructed, and a
|
|
19
19
|
// partial index whose WHERE the renderer can't express is rejected up-front.
|
|
20
|
+
import { isUuid, parseTenantId } from "@cosmicdrift/kumiko-types/identifiers";
|
|
20
21
|
import { configuredBlindIndexKey } from "../../crypto/index.js";
|
|
21
22
|
import { asEntityTableMeta, asRawClient } from "../query.js";
|
|
22
23
|
import { renderTableDdl } from "../render-ddl.js";
|
|
@@ -178,19 +179,80 @@ const UNREACHABLE_SAMPLE_LIMIT = 20;
|
|
|
178
179
|
//
|
|
179
180
|
// Implicit projections only (caller-gated). aggregate_id and the entity id are
|
|
180
181
|
// both uuid, so the anti-join probes the events index without a cast.
|
|
181
|
-
|
|
182
|
+
//
|
|
183
|
+
// Source streams whose rows are keyed by a DERIVED id (r.extendEntityProjection
|
|
184
|
+
// `rowIdOf`, e.g. tenant-salted) cannot be matched on aggregate_id: their
|
|
185
|
+
// distinct (tenant, aggregate) pairs are mapped through rowIdOf into a temp
|
|
186
|
+
// table, and a live row is backed when its id is in that set. The guard stays
|
|
187
|
+
// strict — a row keyed by the raw aggregate id of a derived stream is a ghost.
|
|
188
|
+
const DERIVED_ROW_ID_BATCH_SIZE = 5000;
|
|
189
|
+
const DERIVED_ROW_ID_TABLE = "pg_temp.kumiko_derived_row_ids";
|
|
190
|
+
async function fillDerivedRowIdTable(raw, projectionName, aggregateType, rowIdOf) {
|
|
191
|
+
let cursor;
|
|
192
|
+
for (;;) {
|
|
193
|
+
const page = await raw.unsafe(`SELECT DISTINCT "tenant_id"::text AS tenant_id, "aggregate_id"::text AS aggregate_id
|
|
194
|
+
FROM "kumiko_events"
|
|
195
|
+
WHERE "aggregate_type" = $1
|
|
196
|
+
${cursor ? `AND ("tenant_id", "aggregate_id") > ($2::uuid, $3::uuid)` : ""}
|
|
197
|
+
ORDER BY "tenant_id", "aggregate_id"
|
|
198
|
+
LIMIT ${DERIVED_ROW_ID_BATCH_SIZE}`, cursor ? [aggregateType, cursor.tenant_id, cursor.aggregate_id] : [aggregateType]);
|
|
199
|
+
// skip: keyset exhausted — every source event of this type has been mapped
|
|
200
|
+
if (page.length === 0)
|
|
201
|
+
return;
|
|
202
|
+
const derivedIds = page.map((row) => {
|
|
203
|
+
const tenantId = parseTenantId(row.tenant_id);
|
|
204
|
+
if (tenantId === null) {
|
|
205
|
+
throw new Error(`projection-rebuild "${projectionName}": event of aggregate type "${aggregateType}" ` +
|
|
206
|
+
`carries a non-uuid tenant_id "${row.tenant_id}".`);
|
|
207
|
+
}
|
|
208
|
+
const derived = rowIdOf({ tenantId, aggregateId: row.aggregate_id });
|
|
209
|
+
if (!isUuid(derived)) {
|
|
210
|
+
throw new Error(`projection-rebuild "${projectionName}": rowIdOf for aggregate type "${aggregateType}" ` +
|
|
211
|
+
`returned "${String(derived)}", which is not a uuid (aggregate ${row.aggregate_id}).`);
|
|
212
|
+
}
|
|
213
|
+
return derived;
|
|
214
|
+
});
|
|
215
|
+
await raw.unsafe(`INSERT INTO ${DERIVED_ROW_ID_TABLE} ("id") SELECT unnest($1::uuid[]) ON CONFLICT DO NOTHING`, [derivedIds]);
|
|
216
|
+
// skip: a short page is the last one
|
|
217
|
+
if (page.length < DERIVED_ROW_ID_BATCH_SIZE)
|
|
218
|
+
return;
|
|
219
|
+
cursor = page[page.length - 1];
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
export async function assertNoUnreachableLiveRows(tx, projectionName, tableName, aggregateTypes, derivedRowIds = {}) {
|
|
182
223
|
// skip: no source streams → no events could back any row anyway; a rebuild
|
|
183
224
|
// of a subscription-less projection swaps an empty shadow (handled upstream).
|
|
184
225
|
if (aggregateTypes.length === 0)
|
|
185
226
|
return;
|
|
186
227
|
const raw = asRawClient(tx);
|
|
187
228
|
const t = quoteTableIdent(tableName);
|
|
229
|
+
const derivedTypes = aggregateTypes.filter((type) => derivedRowIds[type] !== undefined);
|
|
230
|
+
const directTypes = aggregateTypes.filter((type) => derivedRowIds[type] === undefined);
|
|
231
|
+
if (derivedTypes.length > 0) {
|
|
232
|
+
// search_path points at the shadow schema here; pg_temp is addressed explicitly.
|
|
233
|
+
await raw.unsafe(`CREATE TEMP TABLE IF NOT EXISTS kumiko_derived_row_ids ("id" uuid PRIMARY KEY) ON COMMIT DROP`);
|
|
234
|
+
await raw.unsafe(`TRUNCATE ${DERIVED_ROW_ID_TABLE}`);
|
|
235
|
+
for (const aggregateType of derivedTypes) {
|
|
236
|
+
const rowIdOf = derivedRowIds[aggregateType];
|
|
237
|
+
// skip: filtered by derivedTypes above, narrows the Record lookup for the compiler
|
|
238
|
+
if (rowIdOf === undefined)
|
|
239
|
+
continue;
|
|
240
|
+
await fillDerivedRowIdTable(raw, projectionName, aggregateType, rowIdOf);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const unbackedClauses = [];
|
|
244
|
+
if (directTypes.length > 0) {
|
|
245
|
+
unbackedClauses.push(`NOT EXISTS (
|
|
246
|
+
SELECT 1 FROM "kumiko_events" e
|
|
247
|
+
WHERE e."aggregate_id" = l."id" AND e."aggregate_type" = ANY($1::text[])
|
|
248
|
+
)`);
|
|
249
|
+
}
|
|
250
|
+
if (derivedTypes.length > 0) {
|
|
251
|
+
unbackedClauses.push(`NOT EXISTS (SELECT 1 FROM ${DERIVED_ROW_ID_TABLE} d WHERE d."id" = l."id")`);
|
|
252
|
+
}
|
|
188
253
|
const ghosts = await raw.unsafe(`SELECT l."id" FROM public.${t} l
|
|
189
|
-
WHERE
|
|
190
|
-
|
|
191
|
-
WHERE e."aggregate_id" = l."id" AND e."aggregate_type" = ANY($1::text[])
|
|
192
|
-
)
|
|
193
|
-
LIMIT ${UNREACHABLE_SAMPLE_LIMIT}`, [aggregateTypes]);
|
|
254
|
+
WHERE ${unbackedClauses.join(" AND ")}
|
|
255
|
+
LIMIT ${UNREACHABLE_SAMPLE_LIMIT}`, directTypes.length > 0 ? [directTypes] : []);
|
|
194
256
|
// skip: every live row has a backing event — nothing unreachable, swap is safe
|
|
195
257
|
if (ghosts.length === 0)
|
|
196
258
|
return;
|
|
@@ -200,7 +262,8 @@ export async function assertNoUnreachableLiveRows(tx, projectionName, tableName,
|
|
|
200
262
|
`event in the projection's source streams and cannot be reconstructed by replay — the swap ` +
|
|
201
263
|
`would silently drop them (ids: ${ids.join(", ")}). A handler direct-inserted these rows ` +
|
|
202
264
|
`without emitting a .created event. Fix: register the table with r.storeTable(meta, ` +
|
|
203
|
-
`{ reason }) to opt out of rebuild, or emit the missing events.
|
|
265
|
+
`{ reason }) to opt out of rebuild, or emit the missing events. Rows keyed by an id ` +
|
|
266
|
+
`derived from the source event declare \`rowIdOf\` on r.extendEntityProjection. See ` +
|
|
204
267
|
`docs/reference/entity-write-patterns.md. Rebuild aborted; live table untouched.`);
|
|
205
268
|
}
|
|
206
269
|
// The bidx column is schema-driven, not key-driven: it exists NULL in a
|