@cosmicdrift/kumiko-framework 0.388.0 → 0.389.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/AGENTS.md +3 -0
- package/agents/examples/golden-path.md +13 -1
- package/agents/examples/lifecycle-hooks.md +55 -2
- package/agents/examples/list-table-options.md +466 -0
- package/agents/examples/pdf-audit-export.md +4 -2
- package/agents/examples/state-machine.md +3 -2
- package/agents/examples/tenant-isolation.md +73 -1
- package/agents/examples.md +1 -0
- package/agents/widgets.md +1 -0
- package/dist/api/api-constants.d.ts +1 -0
- package/dist/api/api-constants.js +2 -0
- package/dist/api/auth-routes-shared.d.ts +2 -0
- package/dist/api/auth-routes-shared.js +5 -0
- package/dist/api/client-error-route.js +2 -3
- package/dist/api/entity-read-access.d.ts +2 -1
- package/dist/api/entity-read-access.js +14 -0
- package/dist/api/export/export-cells-reference.d.ts +9 -0
- package/dist/api/export/export-cells-reference.js +21 -0
- package/dist/api/export/export-cells.d.ts +11 -0
- package/dist/api/export/export-cells.js +136 -0
- package/dist/api/export/export-ciphertext-guard.d.ts +2 -0
- package/dist/api/export/export-ciphertext-guard.js +27 -0
- package/dist/api/export/export-columns.d.ts +29 -0
- package/dist/api/export/export-columns.js +85 -0
- package/dist/api/export/export-errors.d.ts +4 -0
- package/dist/api/export/export-errors.js +14 -0
- package/dist/api/export/export-route.d.ts +4 -0
- package/dist/api/export/export-route.js +74 -0
- package/dist/api/export/export-rows.d.ts +13 -0
- package/dist/api/export/export-rows.js +62 -0
- package/dist/api/export/export-screen.d.ts +12 -0
- package/dist/api/export/export-screen.js +47 -0
- package/dist/api/export/export-xlsx-writer.d.ts +4 -0
- package/dist/api/export/export-xlsx-writer.js +21 -0
- package/dist/api/extra-route.d.ts +4 -0
- package/dist/api/pii-leak-guard.d.ts +3 -0
- package/dist/api/pii-leak-guard.js +3 -2
- package/dist/api/routes.d.ts +16 -0
- package/dist/api/routes.js +2 -2
- package/dist/api/server-extra-routes.js +3 -0
- package/dist/api/server-routes.d.ts +1 -1
- package/dist/api/server-routes.js +8 -2
- package/dist/api/server-wiring.js +2 -0
- package/dist/api/sse-route.d.ts +2 -1
- package/dist/api/sse-route.js +11 -1
- package/dist/config/index.d.ts +3 -1
- package/dist/crypto/pg-kms-adapter.d.ts +1 -0
- package/dist/crypto/pg-kms-adapter.js +12 -18
- package/dist/db/aggregate-sql.d.ts +2 -0
- package/dist/db/aggregate-sql.js +10 -0
- package/dist/db/closed-connection.js +12 -19
- package/dist/db/connection.d.ts +4 -5
- package/dist/db/connection.js +29 -20
- package/dist/db/driver-row-coercion.js +4 -4
- package/dist/db/driver-value-preparation.js +6 -4
- package/dist/db/event-store-executor-read-aggregate-currencies.d.ts +17 -0
- package/dist/db/event-store-executor-read-aggregate-currencies.js +40 -0
- package/dist/db/event-store-executor-read-aggregate.js +36 -5
- package/dist/db/event-store-executor-read-filter.js +6 -3
- package/dist/db/event-store-executor-write-atomic.d.ts +4 -0
- package/dist/db/event-store-executor-write-atomic.js +41 -0
- package/dist/db/event-store-executor-write-steps.d.ts +0 -3
- package/dist/db/event-store-executor-write-steps.js +0 -5
- package/dist/db/event-store-executor-write.js +15 -15
- package/dist/db/event-store-executor.js +2 -1
- package/dist/db/executor-managed-tables.js +1 -1
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.js +1 -1
- package/dist/db/migrate-runner.js +3 -3
- package/dist/db/pg-error.js +26 -21
- package/dist/db/queries/backfill-pii.js +3 -6
- package/dist/db/queries/database-now.js +2 -1
- package/dist/db/queries/shadow-swap.js +2 -3
- package/dist/db/query.d.ts +14 -0
- package/dist/db/query.js +68 -43
- package/dist/db/reference-data.js +14 -2
- package/dist/db/statement-timeout.js +2 -1
- package/dist/db/tenant-db-upsert.d.ts +5 -0
- package/dist/db/tenant-db-upsert.js +91 -0
- package/dist/db/tenant-db.js +11 -1
- package/dist/engine/boot-validator/bundled-lists-table.d.ts +2 -0
- package/dist/engine/boot-validator/bundled-lists-table.js +36 -0
- package/dist/engine/boot-validator/entity-list-aggregate-options.d.ts +3 -0
- package/dist/engine/boot-validator/entity-list-aggregate-options.js +23 -0
- package/dist/engine/boot-validator/list-bulk-actions.d.ts +3 -0
- package/dist/engine/boot-validator/list-bulk-actions.js +49 -0
- package/dist/engine/boot-validator/list-column-layout.d.ts +8 -0
- package/dist/engine/boot-validator/list-column-layout.js +41 -0
- package/dist/engine/boot-validator/list-table-aggregate-options.d.ts +24 -0
- package/dist/engine/boot-validator/list-table-aggregate-options.js +145 -0
- package/dist/engine/boot-validator/list-table-aggregate-query.d.ts +10 -0
- package/dist/engine/boot-validator/list-table-aggregate-query.js +31 -0
- package/dist/engine/boot-validator/list-table-export-options.d.ts +1 -0
- package/dist/engine/boot-validator/list-table-export-options.js +27 -0
- package/dist/engine/boot-validator/list-table-options.d.ts +3 -0
- package/dist/engine/boot-validator/list-table-options.js +77 -0
- package/dist/engine/boot-validator/list-table-reject-unknown-keys.d.ts +1 -0
- package/dist/engine/boot-validator/list-table-reject-unknown-keys.js +7 -0
- package/dist/engine/boot-validator/projection-list-screens.d.ts +1 -1
- package/dist/engine/boot-validator/projection-list-screens.js +3 -13
- package/dist/engine/boot-validator/related-list-table-rules.d.ts +4 -0
- package/dist/engine/boot-validator/related-list-table-rules.js +42 -0
- package/dist/engine/boot-validator/screens-kind-entity-list.js +7 -0
- package/dist/engine/boot-validator/screens-kind-projection-list.js +4 -0
- package/dist/engine/boot-validator/screens-lists.js +12 -2
- package/dist/engine/build-app-schema.d.ts +2 -1
- package/dist/engine/build-app-schema.js +9 -1
- package/dist/engine/build-config-feature-schema.js +6 -1
- package/dist/engine/define-feature.js +4 -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/extractors/round1.d.ts +2 -1
- package/dist/engine/feature-ast/extractors/round1.js +6 -0
- package/dist/engine/feature-ast/index.d.ts +1 -1
- package/dist/engine/feature-ast/parse.js +3 -1
- package/dist/engine/feature-ast/patch.d.ts +2 -0
- package/dist/engine/feature-ast/patch.js +1 -0
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +7 -245
- package/dist/engine/feature-ast/pattern-change-schema.js +9 -163
- package/dist/engine/feature-ast/pattern-id-matchers.js +1 -0
- package/dist/engine/feature-ast/pattern-id-schema.d.ts +251 -0
- package/dist/engine/feature-ast/pattern-id-schema.js +166 -0
- package/dist/engine/feature-ast/patterns.d.ts +5 -1
- package/dist/engine/feature-ast/patterns.js +1 -0
- package/dist/engine/feature-ast/render.js +4 -0
- package/dist/engine/feature-builder-state.d.ts +1 -0
- package/dist/engine/feature-builder-state.js +1 -0
- package/dist/engine/feature-manifest.d.ts +1 -0
- package/dist/engine/feature-manifest.js +1 -0
- package/dist/engine/field-access.d.ts +1 -0
- package/dist/engine/field-access.js +12 -0
- package/dist/engine/index.d.ts +2 -2
- package/dist/engine/index.js +1 -1
- package/dist/engine/pattern-library/library.js +2 -1
- package/dist/engine/pattern-library/static-schemas.d.ts +1 -0
- package/dist/engine/pattern-library/static-schemas.js +9 -0
- package/dist/engine/screen-filter-ops.js +2 -0
- package/dist/engine/strip-disabled-features-from-app-schema.js +7 -3
- package/dist/engine/types/feature.d.ts +1 -0
- package/dist/engine/types/index.d.ts +4 -3
- package/dist/engine/types/screen.d.ts +1 -1
- package/dist/engine/types/screen.js +1 -1
- package/dist/entrypoint/index.d.ts +2 -0
- package/dist/entrypoint/index.js +3 -0
- package/dist/event-store/event-store.d.ts +1 -0
- package/dist/event-store/event-store.js +7 -4
- package/dist/event-store/provenance-append.js +1 -1
- package/dist/files/create-file-ref-from-bytes.js +3 -0
- package/dist/files/create-file-ref.d.ts +3 -0
- package/dist/files/create-file-ref.js +11 -2
- package/dist/files/file-routes.d.ts +2 -0
- package/dist/files/file-routes.js +29 -2
- package/dist/files/provider-resolver.d.ts +1 -0
- package/dist/files/provider-resolver.js +1 -1
- package/dist/files/record-write-check.d.ts +6 -0
- package/dist/files/record-write-check.js +13 -0
- package/dist/files/upload-ledger.d.ts +2 -2
- package/dist/files/upload-ledger.js +4 -4
- package/dist/i18n/required-surface-keys.js +7 -2
- package/dist/internal/testing.js +1 -1
- package/dist/jobs/job-runner-scope.js +6 -6
- package/dist/jobs/job-runner.js +3 -0
- package/dist/observability/escape-hatch-report.js +9 -2
- package/dist/pipeline/after-rollback-sink.d.ts +6 -0
- package/dist/pipeline/after-rollback-sink.js +32 -0
- package/dist/pipeline/dispatch-batch.js +8 -1
- package/dist/pipeline/dispatch-shared.js +4 -0
- package/dist/pipeline/event-dispatcher-delivery.js +3 -1
- package/dist/pipeline/event-dispatcher.d.ts +1 -0
- package/dist/pipeline/event-dispatcher.js +6 -9
- package/dist/pipeline/handler-context-dispatch.d.ts +2 -0
- package/dist/pipeline/handler-context-dispatch.js +13 -1
- package/dist/pipeline/handler-context-events.d.ts +2 -4
- package/dist/pipeline/handler-context-events.js +6 -4
- package/dist/pipeline/system-hooks.d.ts +3 -1
- package/dist/pipeline/system-hooks.js +55 -6
- package/dist/pipeline/write-result-masking.js +13 -10
- package/dist/schema-cli/baseline-command.js +1 -1
- package/dist/schema-cli/status-command.js +1 -1
- package/dist/search/reindex-entity.js +15 -5
- package/dist/ui-types/index.d.ts +2 -2
- package/dist/ui-types/index.js +1 -1
- package/dist/upgrade-changelog.d.ts +2 -0
- package/dist/upgrade-changelog.js +14 -0
- package/dist/upgrade-cli.d.ts +3 -12
- package/dist/upgrade-cli.js +22 -76
- package/dist/upgrade-marker-parse.d.ts +32 -0
- package/dist/upgrade-marker-parse.js +73 -0
- package/package.json +9 -6
- package/src/changes.json +245 -0
- package/src/scripts/codemod/codemod-support.ts +1 -1
- package/src/scripts/codemod/db-factory.ts +1 -1
- package/dist/engine/provider-plugin-context.d.ts +0 -3
- package/dist/engine/provider-plugin-context.js +0 -28
|
@@ -5,7 +5,7 @@ import { extractPgError } from "./pg-error.js";
|
|
|
5
5
|
// A pool can briefly keep handing out connections the server just closed.
|
|
6
6
|
// Each failed attempt discards one, so retries are bounded by pool size.
|
|
7
7
|
const CLOSED_CONNECTION_CODES = new Set([
|
|
8
|
-
"
|
|
8
|
+
"ERR_POSTGRES_CONNECTION_CLOSED", // Bun.SQL
|
|
9
9
|
"57P01", // PG SQLSTATE: admin_shutdown / terminated backend
|
|
10
10
|
]);
|
|
11
11
|
export function isClosedConnectionError(err) {
|
|
@@ -21,40 +21,33 @@ export function isPooledClient(raw) {
|
|
|
21
21
|
typeof propertyOf(raw, "savepoint") !== "function" &&
|
|
22
22
|
typeof propertyOf(raw, "release") !== "function");
|
|
23
23
|
}
|
|
24
|
-
const closedConnectionLog = createFallbackLogger("
|
|
25
|
-
// The driver rejects in-flight queries with this AbortError (no code) when the
|
|
26
|
-
// pool closes under them, so isClosedConnectionError can't see it.
|
|
27
|
-
const DRIVER_CLOSED_ABORT_MESSAGE = "The connection was closed.";
|
|
28
|
-
function isDriverClosedAbort(err) {
|
|
29
|
-
return (err instanceof Error && err.name === "AbortError" && err.message === DRIVER_CLOSED_ABORT_MESSAGE);
|
|
30
|
-
}
|
|
24
|
+
const closedConnectionLog = createFallbackLogger("bun-sql");
|
|
31
25
|
function isHandleLike(raw) {
|
|
32
26
|
return raw !== null && (typeof raw === "object" || typeof raw === "function");
|
|
33
27
|
}
|
|
34
|
-
//
|
|
28
|
+
// Bun.SQL pool and tx handles both carry `flush`.
|
|
35
29
|
function driverOf(raw) {
|
|
36
30
|
if (!isHandleLike(raw))
|
|
37
31
|
return "unknown";
|
|
38
|
-
if (typeof raw["
|
|
39
|
-
return "
|
|
32
|
+
if (typeof raw["flush"] === "function")
|
|
33
|
+
return "bun-sql";
|
|
40
34
|
return "unknown";
|
|
41
35
|
}
|
|
42
36
|
function positiveNumber(v) {
|
|
43
37
|
return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : undefined;
|
|
44
38
|
}
|
|
45
|
-
//
|
|
39
|
+
// Bun.SQL reports its timeout options in milliseconds.
|
|
46
40
|
function timeoutOptionMs(options, driver, optionName) {
|
|
47
|
-
if (driver !== "
|
|
41
|
+
if (driver !== "bun-sql")
|
|
48
42
|
return undefined;
|
|
49
|
-
|
|
50
|
-
return seconds === undefined ? undefined : seconds * 1000;
|
|
43
|
+
return positiveNumber(options[optionName]);
|
|
51
44
|
}
|
|
52
45
|
function errorProp(err, name) {
|
|
53
46
|
return isHandleLike(err) ? err[name] : undefined;
|
|
54
47
|
}
|
|
55
48
|
// Deliberately excludes SQL text and parameters: app SQL can carry PII.
|
|
56
49
|
export function describeClosedConnectionFailure(raw, err, attempts) {
|
|
57
|
-
if (!isClosedConnectionError(err)
|
|
50
|
+
if (!isClosedConnectionError(err))
|
|
58
51
|
return undefined;
|
|
59
52
|
const driver = driverOf(raw);
|
|
60
53
|
const rawOptions = isHandleLike(raw) ? raw["options"] : undefined;
|
|
@@ -64,9 +57,9 @@ export function describeClosedConnectionFailure(raw, err, attempts) {
|
|
|
64
57
|
pooled: isPooledClient(raw),
|
|
65
58
|
attempts,
|
|
66
59
|
poolMax: positiveNumber(options["max"]),
|
|
67
|
-
idleTimeoutMs: timeoutOptionMs(options, driver, "
|
|
68
|
-
maxLifetimeMs: timeoutOptionMs(options, driver, "
|
|
69
|
-
connectionTimeoutMs: timeoutOptionMs(options, driver, "
|
|
60
|
+
idleTimeoutMs: timeoutOptionMs(options, driver, "idleTimeout"),
|
|
61
|
+
maxLifetimeMs: timeoutOptionMs(options, driver, "maxLifetime"),
|
|
62
|
+
connectionTimeoutMs: timeoutOptionMs(options, driver, "connectionTimeout"),
|
|
70
63
|
errorName: errorProp(err, "name"),
|
|
71
64
|
errorCode: extractPgError(err)?.code ?? errorProp(err, "code"),
|
|
72
65
|
errorErrno: errorProp(err, "errno"),
|
package/dist/db/connection.d.ts
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
|
-
import type { DbConnection, PgClient } from "@cosmicdrift/kumiko-types/db-connection";
|
|
2
|
-
import postgres from "postgres";
|
|
1
|
+
import type { DbConnection, PgClient, PgListenClient } from "@cosmicdrift/kumiko-types/db-connection";
|
|
3
2
|
export type * from "@cosmicdrift/kumiko-types/db-connection";
|
|
4
|
-
type
|
|
3
|
+
type BunSqlOptions = Bun.SQL.PostgresOrMySQLOptions;
|
|
5
4
|
export type DbConnectionOptions = {
|
|
6
5
|
readonly maxConnections?: number;
|
|
7
6
|
readonly idleTimeoutSeconds?: number;
|
|
8
7
|
readonly connectTimeoutSeconds?: number;
|
|
9
8
|
readonly closeTimeoutSeconds?: number;
|
|
10
|
-
readonly ssl?:
|
|
11
|
-
readonly onNotice?: (notice: postgres.Notice) => void;
|
|
9
|
+
readonly ssl?: BunSqlOptions["tls"];
|
|
12
10
|
readonly statementTimeoutMs?: number;
|
|
13
11
|
readonly idleInTransactionSessionTimeoutMs?: number;
|
|
14
12
|
readonly lockTimeoutMs?: number;
|
|
@@ -19,6 +17,7 @@ export type CreateDbConnectionOptions = DbConnectionOptions & {
|
|
|
19
17
|
export type DbConnectionHandle = {
|
|
20
18
|
db: DbConnection;
|
|
21
19
|
client: PgClient;
|
|
20
|
+
listenClient: PgListenClient;
|
|
22
21
|
close: () => Promise<void>;
|
|
23
22
|
};
|
|
24
23
|
export declare const DEFAULT_DB_CLOSE_TIMEOUT_SECONDS = 5;
|
package/dist/db/connection.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import postgres from "postgres";
|
|
2
1
|
import { readPositiveIntEnv } from "../utils/env-parse.js";
|
|
3
2
|
// A DB outage can leave an in-flight query referenced on a dead connection,
|
|
4
|
-
// so a plain
|
|
3
|
+
// so a plain close() (no timeout = wait for queries to finish) hangs forever —
|
|
5
4
|
// close() must always bound how long shutdown waits.
|
|
6
5
|
export const DEFAULT_DB_CLOSE_TIMEOUT_SECONDS = 5;
|
|
7
6
|
// Keyed by the pool so withoutStatementTimeout can open a sibling with identical connect options.
|
|
@@ -11,20 +10,28 @@ export function openUnboundedSiblingPool(db) {
|
|
|
11
10
|
return undefined;
|
|
12
11
|
return unboundedSiblingFactories.get(db)?.();
|
|
13
12
|
}
|
|
13
|
+
// Bun.SQL treats `true` and a TLS object without rejectUnauthorized as sslmode=require, which
|
|
14
|
+
// skips certificate checks; postgres-js verified in both cases, so keep that unless turned off.
|
|
15
|
+
function verifiedTls(ssl) {
|
|
16
|
+
if (ssl === true)
|
|
17
|
+
return "verify-full";
|
|
18
|
+
if (typeof ssl === "object" && !(ssl instanceof Blob) && ssl.rejectUnauthorized === undefined) {
|
|
19
|
+
return { ...ssl, rejectUnauthorized: true };
|
|
20
|
+
}
|
|
21
|
+
return ssl;
|
|
22
|
+
}
|
|
14
23
|
export function createDbConnection({ url, ...options }) {
|
|
15
|
-
const
|
|
24
|
+
const sqlOptions = {};
|
|
16
25
|
if (options.maxConnections !== undefined)
|
|
17
|
-
|
|
26
|
+
sqlOptions.max = options.maxConnections;
|
|
18
27
|
if (options.idleTimeoutSeconds !== undefined) {
|
|
19
|
-
|
|
28
|
+
sqlOptions.idleTimeout = options.idleTimeoutSeconds;
|
|
20
29
|
}
|
|
21
30
|
if (options.connectTimeoutSeconds !== undefined) {
|
|
22
|
-
|
|
31
|
+
sqlOptions.connectionTimeout = options.connectTimeoutSeconds;
|
|
23
32
|
}
|
|
24
33
|
if (options.ssl !== undefined)
|
|
25
|
-
|
|
26
|
-
if (options.onNotice !== undefined)
|
|
27
|
-
pgOptions.onnotice = options.onNotice;
|
|
34
|
+
sqlOptions.tls = verifiedTls(options.ssl);
|
|
28
35
|
const serverParameters = {};
|
|
29
36
|
if (options.statementTimeoutMs !== undefined) {
|
|
30
37
|
serverParameters["statement_timeout"] = options.statementTimeoutMs;
|
|
@@ -36,48 +43,50 @@ export function createDbConnection({ url, ...options }) {
|
|
|
36
43
|
if (options.lockTimeoutMs !== undefined)
|
|
37
44
|
serverParameters["lock_timeout"] = options.lockTimeoutMs;
|
|
38
45
|
if (Object.keys(serverParameters).length > 0)
|
|
39
|
-
|
|
40
|
-
|
|
46
|
+
sqlOptions.connection = serverParameters;
|
|
47
|
+
// Bun.SQL opens all `max` connections on the first query, so a pool always costs `max` backends.
|
|
48
|
+
const client = new Bun.SQL(url, sqlOptions);
|
|
41
49
|
// A run that never settles (a caller gave up on it via its own timeout) never reaches the
|
|
42
50
|
// sibling's own close; close() ends it so shutdown and DROP DATABASE are not blocked by it.
|
|
43
51
|
const openSiblings = new Set();
|
|
44
52
|
unboundedSiblingFactories.set(client, () => {
|
|
45
53
|
// Two connections are enough for lock-in-tx plus DDL; the cap keeps parallel rebuilds from exhausting max_connections.
|
|
46
|
-
const sibling =
|
|
47
|
-
...
|
|
54
|
+
const sibling = new Bun.SQL(url, {
|
|
55
|
+
...sqlOptions,
|
|
48
56
|
max: 4,
|
|
49
|
-
connection: { ...
|
|
57
|
+
connection: { ...sqlOptions.connection, statement_timeout: 0 },
|
|
50
58
|
});
|
|
51
59
|
openSiblings.add(sibling);
|
|
52
60
|
return {
|
|
53
61
|
client: sibling,
|
|
54
62
|
close: async (timeoutSeconds) => {
|
|
55
63
|
openSiblings.delete(sibling);
|
|
56
|
-
await sibling.
|
|
64
|
+
await sibling.close({ timeout: timeoutSeconds });
|
|
57
65
|
},
|
|
58
66
|
};
|
|
59
67
|
});
|
|
60
68
|
return {
|
|
61
69
|
db: client,
|
|
62
70
|
client,
|
|
71
|
+
listenClient: client,
|
|
63
72
|
close: async () => {
|
|
64
73
|
const timeout = options.closeTimeoutSeconds ?? DEFAULT_DB_CLOSE_TIMEOUT_SECONDS;
|
|
65
74
|
unboundedSiblingFactories.delete(client);
|
|
66
75
|
const pools = [client, ...openSiblings];
|
|
67
76
|
openSiblings.clear();
|
|
68
|
-
const results = await Promise.allSettled(pools.map((pool) => pool.
|
|
77
|
+
const results = await Promise.allSettled(pools.map((pool) => pool.close({ timeout })));
|
|
69
78
|
const failure = results.find((result) => result.status === "rejected");
|
|
70
79
|
if (failure !== undefined)
|
|
71
80
|
throw failure.reason;
|
|
72
81
|
},
|
|
73
82
|
};
|
|
74
83
|
}
|
|
75
|
-
// DB_PROVIDER
|
|
76
|
-
//
|
|
84
|
+
// DB_PROVIDER once selected between drivers; a config that still sets it is stale,
|
|
85
|
+
// so fail loudly instead of silently ignoring it.
|
|
77
86
|
function assertNoRemovedDbProvider(env) {
|
|
78
87
|
const provider = env["DB_PROVIDER"];
|
|
79
|
-
if (provider !== undefined && provider !== ""
|
|
80
|
-
throw new Error(`DB_PROVIDER="${provider}" is no longer supported:
|
|
88
|
+
if (provider !== undefined && provider !== "") {
|
|
89
|
+
throw new Error(`DB_PROVIDER="${provider}" is no longer supported: Bun.SQL is the only driver. Remove DB_PROVIDER from the environment.`);
|
|
81
90
|
}
|
|
82
91
|
}
|
|
83
92
|
export function dbConnectionOptionsFromEnv(env = process.env) {
|
|
@@ -4,13 +4,13 @@ import { parseJsonSafe } from "../utils/safe-json.js";
|
|
|
4
4
|
import { isTemporalInstant, isTemporalPlainDate } from "./temporal-value-guards.js";
|
|
5
5
|
// --- Value coercion at the driver boundary ------------------------------
|
|
6
6
|
//
|
|
7
|
-
//
|
|
7
|
+
// The driver returns timestamptz as JS Date (or ISO string depending on
|
|
8
8
|
// driver config). Framework contract says timestamptz surfaces as
|
|
9
9
|
// Temporal.Instant — without coercion every read hands callers a Date and
|
|
10
10
|
// downstream code that does .epochMilliseconds / .add(...) crashes.
|
|
11
11
|
//
|
|
12
|
-
// Symmetric on write: Temporal.Instant doesn't bind directly
|
|
13
|
-
//
|
|
12
|
+
// Symmetric on write: Temporal.Instant doesn't bind directly as a driver
|
|
13
|
+
// param — convert to ISO string when the column pgType is timestamptz.
|
|
14
14
|
// Postgres bigint → JS: safe integers become `number` (matches createBigIntField /
|
|
15
15
|
// drizzle mode:"number"); values outside ±2^53 stay `bigint` (money cents, raw
|
|
16
16
|
// unmanaged columns). Uses BigInt equality check so 9007199254740993n never
|
|
@@ -72,7 +72,7 @@ function instantFromDriver(value) {
|
|
|
72
72
|
// the day, so say so once per column instead of returning the wrong day quietly.
|
|
73
73
|
const warnedNonMidnightDateColumns = new Set();
|
|
74
74
|
const MS_PER_DAY = 86_400_000;
|
|
75
|
-
const dateColumnDriftLog = createFallbackLogger("
|
|
75
|
+
const dateColumnDriftLog = createFallbackLogger("bun-sql");
|
|
76
76
|
function warnIfNotDateColumn(epochMs, column) {
|
|
77
77
|
// skip: midnight value is a correct date column, and an already-warned column is deduped
|
|
78
78
|
if (epochMs % MS_PER_DAY === 0 || warnedNonMidnightDateColumns.has(column))
|
|
@@ -33,7 +33,9 @@ function prepareDateValue(value) {
|
|
|
33
33
|
// structured values bind directly with an ::jsonb cast; JSON.stringify first
|
|
34
34
|
// would produce a JSON-string scalar instead of the structured value.
|
|
35
35
|
export function prepareJsonbValue(value) {
|
|
36
|
-
|
|
36
|
+
// Bun.SQL types JS numbers as int4/float8 params, which Postgres refuses for a jsonb column.
|
|
37
|
+
// Non-finite numbers fall through so Postgres rejects them instead of storing JSON null.
|
|
38
|
+
if (typeof value === "boolean" || (typeof value === "number" && Number.isFinite(value))) {
|
|
37
39
|
return { kind: "param", sql: "::text::jsonb", bound: JSON.stringify(value) };
|
|
38
40
|
}
|
|
39
41
|
if (typeof value !== "object" || isTemporalInstant(value)) {
|
|
@@ -42,7 +44,7 @@ export function prepareJsonbValue(value) {
|
|
|
42
44
|
if (!Array.isArray(value)) {
|
|
43
45
|
return { kind: "param", sql: "::jsonb", bound: value };
|
|
44
46
|
}
|
|
45
|
-
// All-boolean arrays are inferred as boolean[] by
|
|
47
|
+
// All-boolean arrays are inferred as boolean[] by the driver; route via text::jsonb.
|
|
46
48
|
if (value.length > 0 && value.every((entry) => typeof entry === "boolean")) {
|
|
47
49
|
return { kind: "param", sql: "::text::jsonb", bound: JSON.stringify(value) };
|
|
48
50
|
}
|
|
@@ -59,7 +61,7 @@ const INTEGER_PG_TYPES = new Set([
|
|
|
59
61
|
"bigserial",
|
|
60
62
|
]);
|
|
61
63
|
// Postgres rounds sub-millisecond params into timestamptz(3); truncating to ms here keeps the
|
|
62
|
-
// stored instant identical across drivers and equal to what
|
|
64
|
+
// stored instant identical across drivers and equal to what a Date param would store.
|
|
63
65
|
export function instantToTimestamptzParam(instant) {
|
|
64
66
|
return Temporal.Instant.fromEpochMilliseconds(instant.epochMilliseconds).toString();
|
|
65
67
|
}
|
|
@@ -67,7 +69,7 @@ function prepareDriverNeutralScalar(value, pgType) {
|
|
|
67
69
|
if ((pgType === "timestamptz" || pgType === "timestamptz(3)") && isTemporalInstant(value)) {
|
|
68
70
|
return { kind: "param", sql: "", bound: instantToTimestamptzParam(value) };
|
|
69
71
|
}
|
|
70
|
-
//
|
|
72
|
+
// Sent as text, so Postgres rejects 3.5 for an integer column instead of rounding it.
|
|
71
73
|
if (typeof value === "number" && pgType !== undefined && INTEGER_PG_TYPES.has(pgType)) {
|
|
72
74
|
return { kind: "param", sql: "", bound: String(value) };
|
|
73
75
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { FieldDefinition } from "@cosmicdrift/kumiko-types/fields";
|
|
2
|
+
import type { ListAggregateCurrencyAmount, ListAggregateMeasureKind } from "@cosmicdrift/kumiko-types/list-aggregate-types";
|
|
3
|
+
import type { OwnershipClause } from "../engine/ownership.ts";
|
|
4
|
+
import type { ExecutorContext } from "./event-store-executor-context.ts";
|
|
5
|
+
import { type ListWhere } from "./event-store-executor-read-filter.ts";
|
|
6
|
+
import type { TenantDb } from "./tenant-db.ts";
|
|
7
|
+
export type CurrencyMeasureCandidate = {
|
|
8
|
+
readonly kind: ListAggregateMeasureKind;
|
|
9
|
+
readonly field: string;
|
|
10
|
+
readonly def: FieldDefinition;
|
|
11
|
+
readonly clause: OwnershipClause;
|
|
12
|
+
};
|
|
13
|
+
export type MeasureCurrencies = ReadonlyMap<string, readonly string[]>;
|
|
14
|
+
export declare function currencyColumnOf(field: string): string;
|
|
15
|
+
export declare function loadMeasureCurrencies(ctx: ExecutorContext, measures: readonly CurrencyMeasureCandidate[], where: ListWhere, db: TenantDb): Promise<MeasureCurrencies>;
|
|
16
|
+
export declare function currencyMeasureAlias(measureIndex: number, currencyIndex: number): string;
|
|
17
|
+
export declare function decodeCurrencyAmounts(row: Readonly<Record<string, unknown>>, measureIndex: number, currencies: readonly string[]): readonly ListAggregateCurrencyAmount[];
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { isPerCurrencyMeasure } from "./aggregate-sql.js";
|
|
2
|
+
import { bindShiftedClause } from "./event-store-executor-read-filter.js";
|
|
3
|
+
import { executeRawQueryRead } from "./queries/raw-sql.js";
|
|
4
|
+
import { schemaTableName } from "./table-introspection.js";
|
|
5
|
+
import { tenantDbRunner } from "./tenant-db-runner.js";
|
|
6
|
+
// Above the ~180 ISO 4217 codes, so the cap only bounds malformed data.
|
|
7
|
+
const MAX_AGGREGATE_CURRENCIES = 200;
|
|
8
|
+
export function currencyColumnOf(field) {
|
|
9
|
+
return `${field}Currency`;
|
|
10
|
+
}
|
|
11
|
+
// Runs before the measure query mutates `where`, on a copy of its params, and
|
|
12
|
+
// under the money field's own read clause: an unreadable amount must not reveal
|
|
13
|
+
// which currencies exist.
|
|
14
|
+
export async function loadMeasureCurrencies(ctx, measures, where, db) {
|
|
15
|
+
const byField = new Map();
|
|
16
|
+
const tableName = String(schemaTableName(ctx.table));
|
|
17
|
+
for (const m of measures) {
|
|
18
|
+
if (!isPerCurrencyMeasure(m.kind, m.def.type) || byField.has(m.field))
|
|
19
|
+
continue;
|
|
20
|
+
if (!Object.hasOwn(ctx.table, currencyColumnOf(m.field)))
|
|
21
|
+
continue;
|
|
22
|
+
const params = [...where.params];
|
|
23
|
+
const conditions = [...where.whereSql, `${where.colSql(m.field)} IS NOT NULL`];
|
|
24
|
+
if (m.clause.kind === "sql")
|
|
25
|
+
conditions.push(`(${bindShiftedClause(params, m.clause)})`);
|
|
26
|
+
const currencySql = where.colSql(currencyColumnOf(m.field));
|
|
27
|
+
const rows = await executeRawQueryRead(tenantDbRunner(db), `SELECT DISTINCT ${currencySql} AS cur FROM "${tableName}" WHERE ${conditions.join(" AND ")} AND ${currencySql} IS NOT NULL ORDER BY 1 LIMIT ${MAX_AGGREGATE_CURRENCIES}`, params);
|
|
28
|
+
byField.set(m.field, rows.map((row) => String(row.cur)));
|
|
29
|
+
}
|
|
30
|
+
return byField;
|
|
31
|
+
}
|
|
32
|
+
export function currencyMeasureAlias(measureIndex, currencyIndex) {
|
|
33
|
+
return `m${measureIndex}_${currencyIndex}`;
|
|
34
|
+
}
|
|
35
|
+
export function decodeCurrencyAmounts(row, measureIndex, currencies) {
|
|
36
|
+
return currencies.flatMap((currency, j) => {
|
|
37
|
+
const raw = row[currencyMeasureAlias(measureIndex, j)];
|
|
38
|
+
return raw === null || raw === undefined ? [] : [{ currency, value: Number(raw) }];
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ValidationError } from "../errors/index.js";
|
|
2
|
-
import { aggregateKeyOf, decodeListAggregateValue, emptyListAggregateValue, groupedKeyOrderLimitSql, isListAggregateGroupable, isListAggregateMeasureSupported, listAggregateKeySql, listAggregateMeasureSql, } from "./aggregate-sql.js";
|
|
2
|
+
import { aggregateKeyOf, decodeListAggregateValue, emptyListAggregateValue, groupedKeyOrderLimitSql, isListAggregateGroupable, isListAggregateMeasureSupported, isPerCurrencyMeasure, listAggregateKeySql, listAggregateMeasureSql, listCurrencyMeasureSql, } from "./aggregate-sql.js";
|
|
3
3
|
import { holdsCiphertextOrSecret } from "./entity-field-encryption.js";
|
|
4
|
+
import { currencyColumnOf, currencyMeasureAlias, decodeCurrencyAmounts, loadMeasureCurrencies, } from "./event-store-executor-read-aggregate-currencies.js";
|
|
4
5
|
import { appendShiftedClause, bindShiftedClause, } from "./event-store-executor-read-filter.js";
|
|
5
6
|
import { buildListWhere, createFieldReadClause, } from "./event-store-executor-read-where.js";
|
|
6
7
|
import { executeRawQueryRead } from "./queries/raw-sql.js";
|
|
@@ -61,9 +62,26 @@ function planAggregate(ctx, fieldReadClause, aggregate) {
|
|
|
61
62
|
return { groupBy, measures };
|
|
62
63
|
}
|
|
63
64
|
function emptyMeasureValues(plan) {
|
|
64
|
-
return plan.measures.map((m) =>
|
|
65
|
+
return plan.measures.map((m) => {
|
|
66
|
+
if (m.gate.kind === "silent")
|
|
67
|
+
return null;
|
|
68
|
+
return isPerCurrencyMeasure(m.kind, m.gate.def.type) ? [] : emptyListAggregateValue(m.kind);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
function currencyCandidates(plan) {
|
|
72
|
+
return plan.measures.flatMap((m) => m.gate.kind === "ok"
|
|
73
|
+
? [{ kind: m.kind, field: m.field, def: m.gate.def, clause: m.gate.clause }]
|
|
74
|
+
: []);
|
|
65
75
|
}
|
|
66
|
-
function
|
|
76
|
+
function currencyMeasureSelects(measureIndex, kind, field, readFilterSql, currencies, where) {
|
|
77
|
+
return currencies.map((currency, j) => {
|
|
78
|
+
where.params.push(currency);
|
|
79
|
+
const currencyMatch = `${where.colSql(currencyColumnOf(field))} = $${where.params.length}`;
|
|
80
|
+
const filterSql = readFilterSql === undefined ? currencyMatch : `${currencyMatch} AND (${readFilterSql})`;
|
|
81
|
+
return `${listCurrencyMeasureSql(kind, where.colSql(field), filterSql)} AS ${currencyMeasureAlias(measureIndex, j)}`;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
function renderSelectList(plan, where, currencies) {
|
|
67
85
|
const parts = [];
|
|
68
86
|
if (plan.groupBy?.gate.kind === "ok") {
|
|
69
87
|
const { def, clause } = plan.groupBy.gate;
|
|
@@ -77,6 +95,11 @@ function renderSelectList(plan, where) {
|
|
|
77
95
|
if (m.gate.kind === "silent")
|
|
78
96
|
continue; // skip: an unreadable measure stays out of the SELECT and decodes to null
|
|
79
97
|
const filterSql = m.gate.clause.kind === "sql" ? bindShiftedClause(where.params, m.gate.clause) : undefined;
|
|
98
|
+
if (isPerCurrencyMeasure(m.kind, m.gate.def.type)) {
|
|
99
|
+
const fieldCurrencies = currencies.get(m.field) ?? [];
|
|
100
|
+
parts.push(...currencyMeasureSelects(i, m.kind, m.field, filterSql, fieldCurrencies, where));
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
80
103
|
parts.push(`${listAggregateMeasureSql(m.kind, m.gate.def.type, where.colSql(m.field), filterSql)} AS m${i}`);
|
|
81
104
|
}
|
|
82
105
|
return parts.join(", ");
|
|
@@ -93,7 +116,8 @@ export async function aggregateList(ctx, payload, user, db, runtimeOptions) {
|
|
|
93
116
|
};
|
|
94
117
|
}
|
|
95
118
|
const { where, searchTruncated } = built;
|
|
96
|
-
const
|
|
119
|
+
const currencies = await loadMeasureCurrencies(ctx, currencyCandidates(plan), where, db);
|
|
120
|
+
const selectList = renderSelectList(plan, where, currencies);
|
|
97
121
|
const whereText = where.whereSql.length > 0 ? ` WHERE ${where.whereSql.join(" AND ")}` : "";
|
|
98
122
|
const groupText = grouped ? groupedKeyOrderLimitSql(MAX_AGGREGATE_GROUPS + 1) : "";
|
|
99
123
|
const tableName = String(schemaTableName(ctx.table));
|
|
@@ -102,7 +126,14 @@ export async function aggregateList(ctx, payload, user, db, runtimeOptions) {
|
|
|
102
126
|
const groups = rows.slice(0, MAX_AGGREGATE_GROUPS).map((row) => ({
|
|
103
127
|
key: grouped ? aggregateKeyOf(row["k0"]) : null,
|
|
104
128
|
count: Number(row["c"]),
|
|
105
|
-
measures: plan.measures.map((m, i) =>
|
|
129
|
+
measures: plan.measures.map((m, i) => {
|
|
130
|
+
if (m.gate.kind === "silent")
|
|
131
|
+
return null;
|
|
132
|
+
if (isPerCurrencyMeasure(m.kind, m.gate.def.type)) {
|
|
133
|
+
return decodeCurrencyAmounts(row, i, currencies.get(m.field) ?? []);
|
|
134
|
+
}
|
|
135
|
+
return decodeListAggregateValue(row[`m${i}`]);
|
|
136
|
+
}),
|
|
106
137
|
}));
|
|
107
138
|
return {
|
|
108
139
|
groups,
|
|
@@ -58,7 +58,7 @@ function applyMultiSelectFilter(colSql, whereSql, params, f) {
|
|
|
58
58
|
// skip: empty candidate list is unsatisfiable
|
|
59
59
|
return;
|
|
60
60
|
}
|
|
61
|
-
// Bound as text then cast: a JS array makes
|
|
61
|
+
// Bound as text then cast: a JS array makes the driver infer boolean[]/int4[]
|
|
62
62
|
// for client-supplied non-string values, and `boolean[]::jsonb` is rejected
|
|
63
63
|
// (500 on client input). An array value means "contains all of these".
|
|
64
64
|
params.push(JSON.stringify(candidates));
|
|
@@ -163,6 +163,9 @@ export function collectListFilters(payload) {
|
|
|
163
163
|
const single = payload.filter !== undefined ? [payload.filter] : [];
|
|
164
164
|
return payload.filters !== undefined ? [...single, ...payload.filters] : single;
|
|
165
165
|
}
|
|
166
|
+
function isJsonbArrayField(field) {
|
|
167
|
+
return field?.type === "multiSelect" || (field?.type === "reference" && field.multiple === true);
|
|
168
|
+
}
|
|
166
169
|
export function applyListFilter(where, f, table, entity, fieldReadClause) {
|
|
167
170
|
if (!Object.hasOwn(table, f.field)) {
|
|
168
171
|
// skip: unknown field — not a real column, drop the filter (injection guard)
|
|
@@ -194,9 +197,9 @@ export function applyListFilter(where, f, table, entity, fieldReadClause) {
|
|
|
194
197
|
}
|
|
195
198
|
if (readClause.kind === "sql")
|
|
196
199
|
appendShiftedClause(where, readClause);
|
|
197
|
-
if (entity.fields[f.field]
|
|
200
|
+
if (isJsonbArrayField(entity.fields[f.field])) {
|
|
198
201
|
applyMultiSelectFilter(where.colSql, where.whereSql, where.params, f);
|
|
199
|
-
// skip:
|
|
202
|
+
// skip: jsonb-array field already handled — don't fall through to applyScreenFilter
|
|
200
203
|
return;
|
|
201
204
|
}
|
|
202
205
|
applyScreenFilter(table, where.colSql, where.whereSql, where.params, f);
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { EventStoreExecutor } from "@cosmicdrift/kumiko-types/event-store-executor-types";
|
|
2
|
+
type WriteVerbs = Pick<EventStoreExecutor, "create" | "update" | "delete" | "forget" | "restore">;
|
|
3
|
+
export declare function withOwnTransactionOnPool(verbs: WriteVerbs): WriteVerbs;
|
|
4
|
+
export {};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { runnerCanBeginTransaction } from "./query.js";
|
|
2
|
+
import { runInOwnTransaction } from "./tenant-db.js";
|
|
3
|
+
import { isBoundTenantDb, tenantDbRunner, tenantDbWritesDeniedReason } from "./tenant-db-runner.js";
|
|
4
|
+
// Aborts the surrounding transaction when a verb returns a failure instead of throwing.
|
|
5
|
+
class RollbackOnFailure extends Error {
|
|
6
|
+
}
|
|
7
|
+
// Pool runners have no savepoint, so append, projection and hooks would otherwise commit as
|
|
8
|
+
// separate autocommit statements. Tx and savepoint runners already provide atomicity.
|
|
9
|
+
async function runVerbAtomically(db, run) {
|
|
10
|
+
// Unbound and denied handles keep their original error, a fresh transaction would reword it.
|
|
11
|
+
if (!isBoundTenantDb(db) ||
|
|
12
|
+
tenantDbWritesDeniedReason(db) !== undefined ||
|
|
13
|
+
!runnerCanBeginTransaction(tenantDbRunner(db))) {
|
|
14
|
+
return run(db);
|
|
15
|
+
}
|
|
16
|
+
let failure;
|
|
17
|
+
try {
|
|
18
|
+
return await runInOwnTransaction(db, async (txDb) => {
|
|
19
|
+
const result = await run(txDb);
|
|
20
|
+
if (!result.isSuccess) {
|
|
21
|
+
failure = result;
|
|
22
|
+
throw new RollbackOnFailure();
|
|
23
|
+
}
|
|
24
|
+
return result;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
if (error instanceof RollbackOnFailure && failure !== undefined)
|
|
29
|
+
return failure;
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function withOwnTransactionOnPool(verbs) {
|
|
34
|
+
return {
|
|
35
|
+
create: (payload, user, db, options) => runVerbAtomically(db, (txDb) => verbs.create(payload, user, txDb, options)),
|
|
36
|
+
update: (payload, user, db, options) => runVerbAtomically(db, (txDb) => verbs.update(payload, user, txDb, options)),
|
|
37
|
+
delete: (payload, user, db, options) => runVerbAtomically(db, (txDb) => verbs.delete(payload, user, txDb, options)),
|
|
38
|
+
forget: (payload, user, db, options) => runVerbAtomically(db, (txDb) => verbs.forget(payload, user, txDb, options)),
|
|
39
|
+
restore: (payload, user, db, options) => runVerbAtomically(db, (txDb) => verbs.restore(payload, user, txDb, options)),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -5,9 +5,6 @@ import type { EntityId } from "../engine/types/index.ts";
|
|
|
5
5
|
import { type UnprocessableOpts, type WriteFailure, writeFailure } from "../errors/index.ts";
|
|
6
6
|
import type { StoredEvent } from "../event-store/index.ts";
|
|
7
7
|
import type { DbRow, DbRunner } from "./connection.ts";
|
|
8
|
-
export declare function withoutEventInJson<T extends {
|
|
9
|
-
readonly event: unknown;
|
|
10
|
-
}>(result: T): T;
|
|
11
8
|
export declare function projectEntityEvent(db: TenantDb, runner: DbRunner, event: StoredEvent): Promise<void>;
|
|
12
9
|
export declare function selectOptionNotAvailableFailure(entries: readonly UnavailableSelectOption[]): WriteFailure | undefined;
|
|
13
10
|
export type PreSaveFn = (changes: Record<string, unknown>, previous: Record<string, unknown>, isNew: boolean) => Promise<Record<string, unknown>>;
|
|
@@ -2,11 +2,6 @@ import { VersionConflictError as FrameworkVersionConflict, UnprocessableError, w
|
|
|
2
2
|
import { hasLiveProjectionsForEvent, runProjectionsForEvent, } from "../pipeline/projections-runner.js";
|
|
3
3
|
import { projectionRegistryOf } from "./projection-registry-binding.js";
|
|
4
4
|
import { runInSavepointIfSupported } from "./query.js";
|
|
5
|
-
// The echoed event carries entity PII and must never reach a handler's JSON response, while
|
|
6
|
-
// hooks and projections still read it by property. Non-enumerable keeps it out of spread/stringify.
|
|
7
|
-
export function withoutEventInJson(result) {
|
|
8
|
-
return Object.defineProperty(result, "event", { value: result.event, enumerable: false });
|
|
9
|
-
}
|
|
10
5
|
// Custom projections for a write that did not go through the dispatcher (hook or
|
|
11
6
|
// job writing a second aggregate). Savepoint like applyEntityEvent: a throwing
|
|
12
7
|
// projection fails the write and the caller's transaction rolls it back.
|
|
@@ -12,7 +12,7 @@ import { applyEntityEvent } from "./apply-entity-event.js";
|
|
|
12
12
|
import { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types.js";
|
|
13
13
|
import { WritePurposes, } from "./event-store-executor.js";
|
|
14
14
|
import { buildEventMetadata, entityEventName, isForeignTenantOnGlobalEntity, ownershipSubject, tryMapUniqueViolation, } from "./event-store-executor-context.js";
|
|
15
|
-
import { optimisticLockFailure, ownershipDeniedFailure, projectEntityEvent, runPreSave, selectOptionNotAvailableFailure,
|
|
15
|
+
import { optimisticLockFailure, ownershipDeniedFailure, projectEntityEvent, runPreSave, selectOptionNotAvailableFailure, } from "./event-store-executor-write-steps.js";
|
|
16
16
|
import { isFrameworkSystemUser, isLockGuardBypassed } from "./lock-guard.js";
|
|
17
17
|
import { runInSavepointIfSupported } from "./query.js";
|
|
18
18
|
import { assertPersonalDataWrite, tableNameOf, withExecutorManagedWrites } from "./tenant-db.js";
|
|
@@ -244,8 +244,8 @@ export function createWriteVerbs(ctx) {
|
|
|
244
244
|
// legitimately collide — the same ID has an existing stream — so return
|
|
245
245
|
// version_conflict instead of internal_error. Update uses the same catch.
|
|
246
246
|
try {
|
|
247
|
-
// Savepoint-scoped:
|
|
248
|
-
//
|
|
247
|
+
// Savepoint-scoped: Postgres aborts the WHOLE surrounding
|
|
248
|
+
// transaction once any statement inside it errors, even if the JS error
|
|
249
249
|
// is caught — a losing concurrent create's unique-violation
|
|
250
250
|
// would otherwise abort the caller's outer transaction and surface as
|
|
251
251
|
// internal_error at commit time instead of the version_conflict this
|
|
@@ -287,8 +287,8 @@ export function createWriteVerbs(ctx) {
|
|
|
287
287
|
// designer/frontend can show a clear "duplicate" message instead of a cryptic
|
|
288
288
|
// "internal server error".
|
|
289
289
|
// Savepoint like the append: an unclassified DB error from the projection
|
|
290
|
-
// write would otherwise poison the enclosing transaction (
|
|
291
|
-
// whole
|
|
290
|
+
// write would otherwise poison the enclosing transaction (Postgres aborts the
|
|
291
|
+
// whole transaction once any statement in it fails). The savepoint rolls back the failed
|
|
292
292
|
// write while allowing the error to propagate and remain catchable.
|
|
293
293
|
async function applyProjection(runner, event, noRowMessage) {
|
|
294
294
|
let result;
|
|
@@ -462,7 +462,7 @@ export function createWriteVerbs(ctx) {
|
|
|
462
462
|
await projectEntityEvent(db, runner, echoEvent);
|
|
463
463
|
return {
|
|
464
464
|
isSuccess: true,
|
|
465
|
-
data:
|
|
465
|
+
data: {
|
|
466
466
|
kind: "save",
|
|
467
467
|
id: aggregateId,
|
|
468
468
|
data: projection,
|
|
@@ -473,7 +473,7 @@ export function createWriteVerbs(ctx) {
|
|
|
473
473
|
// Persisted event carries ciphertext by design — the caller-facing
|
|
474
474
|
// echo must be plaintext like every other response field.
|
|
475
475
|
event: echoEvent,
|
|
476
|
-
}
|
|
476
|
+
},
|
|
477
477
|
};
|
|
478
478
|
},
|
|
479
479
|
async update(payload, user, db, updateOptions) {
|
|
@@ -526,7 +526,7 @@ export function createWriteVerbs(ctx) {
|
|
|
526
526
|
const { data, echoEvent } = persisted;
|
|
527
527
|
return {
|
|
528
528
|
isSuccess: true,
|
|
529
|
-
data:
|
|
529
|
+
data: {
|
|
530
530
|
kind: "save",
|
|
531
531
|
id: data["id"], // @cast-boundary engine-payload
|
|
532
532
|
data,
|
|
@@ -535,7 +535,7 @@ export function createWriteVerbs(ctx) {
|
|
|
535
535
|
isNew: false,
|
|
536
536
|
entityName,
|
|
537
537
|
event: echoEvent,
|
|
538
|
-
}
|
|
538
|
+
},
|
|
539
539
|
};
|
|
540
540
|
}
|
|
541
541
|
catch (e) {
|
|
@@ -622,14 +622,14 @@ export function createWriteVerbs(ctx) {
|
|
|
622
622
|
await projectEntityEvent(db, runner, echoEvent);
|
|
623
623
|
return {
|
|
624
624
|
isSuccess: true,
|
|
625
|
-
data:
|
|
625
|
+
data: {
|
|
626
626
|
kind: "delete",
|
|
627
627
|
id: payload.id,
|
|
628
628
|
data: existing,
|
|
629
629
|
entityName,
|
|
630
630
|
event: echoEvent,
|
|
631
631
|
...(isLockGuardBypassed(user, options) && { lockGuardBypassed: true }),
|
|
632
|
-
}
|
|
632
|
+
},
|
|
633
633
|
};
|
|
634
634
|
},
|
|
635
635
|
// Hard-purge (Art. 17). Same shape as delete(), but emits `forgotten` which
|
|
@@ -695,14 +695,14 @@ export function createWriteVerbs(ctx) {
|
|
|
695
695
|
await projectEntityEvent(db, runner, echoEvent);
|
|
696
696
|
return {
|
|
697
697
|
isSuccess: true,
|
|
698
|
-
data:
|
|
698
|
+
data: {
|
|
699
699
|
kind: "delete",
|
|
700
700
|
id: payload.id,
|
|
701
701
|
data: existing,
|
|
702
702
|
entityName,
|
|
703
703
|
event: echoEvent,
|
|
704
704
|
lockGuardBypassed: true,
|
|
705
|
-
}
|
|
705
|
+
},
|
|
706
706
|
};
|
|
707
707
|
},
|
|
708
708
|
async restore(payload, user, db, options) {
|
|
@@ -761,7 +761,7 @@ export function createWriteVerbs(ctx) {
|
|
|
761
761
|
await projectEntityEvent(db, runner, echoEvent);
|
|
762
762
|
return {
|
|
763
763
|
isSuccess: true,
|
|
764
|
-
data:
|
|
764
|
+
data: {
|
|
765
765
|
kind: "save",
|
|
766
766
|
id: payload.id,
|
|
767
767
|
data: restoredHydrated,
|
|
@@ -770,7 +770,7 @@ export function createWriteVerbs(ctx) {
|
|
|
770
770
|
isNew: false,
|
|
771
771
|
entityName,
|
|
772
772
|
event: echoEvent,
|
|
773
|
-
}
|
|
773
|
+
},
|
|
774
774
|
};
|
|
775
775
|
},
|
|
776
776
|
};
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { buildExecutorContext } from "./event-store-executor-context.js";
|
|
2
2
|
import { createReadVerbs } from "./event-store-executor-read.js";
|
|
3
3
|
import { createWriteVerbs } from "./event-store-executor-write.js";
|
|
4
|
+
import { withOwnTransactionOnPool } from "./event-store-executor-write-atomic.js";
|
|
4
5
|
export { WritePurposes } from "@cosmicdrift/kumiko-types/event-store-executor-types";
|
|
5
6
|
export { entityEventName } from "./event-store-executor-context.js";
|
|
6
7
|
export function createEventStoreExecutor(table, entity, options) {
|
|
7
8
|
const ctx = buildExecutorContext(table, entity, options);
|
|
8
9
|
return {
|
|
9
|
-
...createWriteVerbs(ctx),
|
|
10
|
+
...withOwnTransactionOnPool(createWriteVerbs(ctx)),
|
|
10
11
|
...createReadVerbs(ctx),
|
|
11
12
|
};
|
|
12
13
|
}
|