@better-schemic/core 0.1.0-alpha.1
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/LICENSE +21 -0
- package/README.md +40 -0
- package/lib/authoring.d.ts +114 -0
- package/lib/authoring.js +242 -0
- package/lib/authoring.js.map +1 -0
- package/lib/chunk-26D7WX7Q.js +31 -0
- package/lib/chunk-26D7WX7Q.js.map +1 -0
- package/lib/chunk-IUPOUD4L.js +330 -0
- package/lib/chunk-IUPOUD4L.js.map +1 -0
- package/lib/chunk-LC3VHUM2.js +56 -0
- package/lib/chunk-LC3VHUM2.js.map +1 -0
- package/lib/chunk-RSGP7GVO.js +252 -0
- package/lib/chunk-RSGP7GVO.js.map +1 -0
- package/lib/client-HZF4ZWGO.js +13 -0
- package/lib/client-HZF4ZWGO.js.map +1 -0
- package/lib/config-BYh7WA4P.d.ts +259 -0
- package/lib/config.d.ts +2 -0
- package/lib/config.js +27 -0
- package/lib/config.js.map +1 -0
- package/lib/driver-LVldBEhS.d.ts +818 -0
- package/lib/driver.d.ts +151 -0
- package/lib/driver.js +47 -0
- package/lib/driver.js.map +1 -0
- package/lib/index.d.ts +154 -0
- package/lib/index.js +758 -0
- package/lib/index.js.map +1 -0
- package/lib/query.d.ts +81 -0
- package/lib/query.js +30 -0
- package/lib/query.js.map +1 -0
- package/lib/secrets-BETi5p8g.d.ts +26 -0
- package/lib/testing.d.ts +99 -0
- package/lib/testing.js +212 -0
- package/lib/testing.js.map +1 -0
- package/package.json +102 -0
- package/src/authoring.ts +360 -0
- package/src/cli-kit/config.ts +226 -0
- package/src/cli-kit/diff.ts +273 -0
- package/src/cli-kit/filter.ts +159 -0
- package/src/cli-kit/merge.ts +380 -0
- package/src/cli-kit/meta.ts +123 -0
- package/src/cli-kit/pager.ts +42 -0
- package/src/cli-kit/schema.ts +214 -0
- package/src/cli-kit/style.ts +24 -0
- package/src/client.ts +244 -0
- package/src/config.ts +199 -0
- package/src/connection.ts +120 -0
- package/src/driver/driver.ts +413 -0
- package/src/driver/index.ts +31 -0
- package/src/driver/portable-ir.ts +51 -0
- package/src/driver/portable.ts +124 -0
- package/src/driver/sdk.ts +73 -0
- package/src/index.ts +185 -0
- package/src/kind/index.ts +28 -0
- package/src/kind/plan.ts +412 -0
- package/src/kind/registry.ts +270 -0
- package/src/query/call.ts +21 -0
- package/src/query/codec.ts +33 -0
- package/src/query/index.ts +22 -0
- package/src/query/project.ts +25 -0
- package/src/query/ref.ts +32 -0
- package/src/query.ts +5 -0
- package/src/secrets.ts +61 -0
- package/src/seed.ts +14 -0
- package/src/testing.ts +402 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { CallableFunctions } from "../driver/driver";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Invoke a defined DB function via the driver's `callable` capability and **decode its result through the
|
|
6
|
+
* function's `.returns(R)` schema** — the neutral half of the query layer's (B) `.call()`. A driver's
|
|
7
|
+
* `defineFunction(args).returns(R).call(db, appArgs)` composes this: it encodes `appArgs` to wire (via
|
|
8
|
+
* the arg schemas) and passes `R` here. Decode-by-default is the differentiator — results come back as
|
|
9
|
+
* real `App` types (`Date`, `RecordId`, …), not wire. A `.raw()` path skips this and returns
|
|
10
|
+
* `callable.invoke(...)` directly.
|
|
11
|
+
*/
|
|
12
|
+
export async function callFunction<S extends z.ZodType>(
|
|
13
|
+
callable: CallableFunctions,
|
|
14
|
+
conn: unknown,
|
|
15
|
+
name: string,
|
|
16
|
+
args: Record<string, unknown>,
|
|
17
|
+
returns: S,
|
|
18
|
+
): Promise<z.output<S>> {
|
|
19
|
+
const raw = await callable.invoke(conn, name, args);
|
|
20
|
+
return z.decode(returns, raw as never);
|
|
21
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One selected projection column: the output key (`as`) plus the source Zod schema to decode it through.
|
|
5
|
+
* The schema may itself be a `z.object(...)` for a nested projection — the driver's builder assembles the
|
|
6
|
+
* tree; core just decodes it.
|
|
7
|
+
*/
|
|
8
|
+
export interface ProjectionField {
|
|
9
|
+
readonly as: string;
|
|
10
|
+
readonly schema: z.ZodType;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Build an ad-hoc Zod object codec for a projection (a subset / rename of a table's columns). A full-row
|
|
15
|
+
* read decodes through the driver's `TableDef`; a *projection* isn't a full row, so this assembles a
|
|
16
|
+
* codec from exactly the selected columns' schemas.
|
|
17
|
+
*/
|
|
18
|
+
export function projectionSchema(
|
|
19
|
+
fields: readonly ProjectionField[],
|
|
20
|
+
): z.ZodObject<Record<string, z.ZodType>> {
|
|
21
|
+
const shape: Record<string, z.ZodType> = {};
|
|
22
|
+
for (const f of fields) shape[f.as] = f.schema;
|
|
23
|
+
return z.object(shape);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Decode raw projected rows (DB wire → app values) through the ad-hoc projection codec. */
|
|
27
|
+
export function decodeProjection<T = Record<string, unknown>>(
|
|
28
|
+
fields: readonly ProjectionField[],
|
|
29
|
+
rows: readonly unknown[],
|
|
30
|
+
): T[] {
|
|
31
|
+
const schema = projectionSchema(fields);
|
|
32
|
+
return rows.map((r) => z.decode(schema, r as never) as T);
|
|
33
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@better-schemic/core/query` — the dialect-neutral query toolkit. NOT a query builder: builders are
|
|
3
|
+
* driver-owned (each driver ships its own at `@better-schemic/<driver>/query`). Core owns the *machinery* every
|
|
4
|
+
* driver builder reuses so the hard parts aren't reimplemented per driver:
|
|
5
|
+
*
|
|
6
|
+
* - `FieldRefBase<T>` — the carrier a driver's field ref extends, so result inference is cross-driver.
|
|
7
|
+
* - `Project<P>` — projection result-type inference (`.return(row => P)` → the decoded shape).
|
|
8
|
+
* - the projection codec — decode a projected (subset/renamed) row at runtime.
|
|
9
|
+
* - `callFunction` — invoke a defined DB function via the `callable` capability + decode through
|
|
10
|
+
* `.returns(R)` (the neutral half of the (B) `.call()`).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// Re-exported so a driver builds its `.call()` from one import (`@better-schemic/core/query`).
|
|
14
|
+
export type { CallableFunctions } from "../driver/driver";
|
|
15
|
+
export { callFunction } from "./call";
|
|
16
|
+
export {
|
|
17
|
+
decodeProjection,
|
|
18
|
+
type ProjectionField,
|
|
19
|
+
projectionSchema,
|
|
20
|
+
} from "./codec";
|
|
21
|
+
export type { Project } from "./project";
|
|
22
|
+
export { brandRef, type FieldRefBase, type RefValue } from "./ref";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { FieldRefBase } from "./ref";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Infer the result element type of a `.return(row => P)` projection: replace every field ref in the
|
|
5
|
+
* returned shape `P` with the decoded app value it carries, recursing into nested objects and arrays.
|
|
6
|
+
* This is the type-level half of result typing (surqlize's `InheritableIntoType` analog); the runtime
|
|
7
|
+
* half is the projection codec (./codec). Generic over ANY driver ref — it only reads the
|
|
8
|
+
* `FieldRefBase` carrier, never a concrete driver type.
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* type R = Project<{ name: Ref<string>; meta: { at: Ref<Date> } }>;
|
|
12
|
+
* // ^? { name: string; meta: { at: Date } }
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* Refs are matched BEFORE the generic object branch, so a ref (which is itself an object carrying
|
|
16
|
+
* operator methods) is unwrapped to its value rather than mapped field-by-field.
|
|
17
|
+
*/
|
|
18
|
+
export type Project<P> =
|
|
19
|
+
P extends FieldRefBase<infer T>
|
|
20
|
+
? T
|
|
21
|
+
: P extends readonly (infer E)[]
|
|
22
|
+
? Project<E>[]
|
|
23
|
+
: P extends object
|
|
24
|
+
? { [K in keyof P]: Project<P[K]> }
|
|
25
|
+
: P;
|
package/src/query/ref.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The neutral carrier a driver's field reference extends so the core projection inference can read its
|
|
3
|
+
* app-value type — the cross-driver contract for `@better-schemic/core/query`. Builders are driver-owned (each
|
|
4
|
+
* driver ships its own `FieldRef` with its own operators at `@better-schemic/<driver>/query`); the ONE thing
|
|
5
|
+
* core needs from any such ref is the *decoded app value* it stands for, carried here as a phantom.
|
|
6
|
+
*
|
|
7
|
+
* A driver's ref does: `interface SurrealRef<T> extends FieldRefBase<T> { eq(v: T): Expr; … }`.
|
|
8
|
+
* `Project` (./project) then reads `T` back out of any ref in a returned projection shape.
|
|
9
|
+
*/
|
|
10
|
+
declare const REF_VALUE: unique symbol;
|
|
11
|
+
|
|
12
|
+
export interface FieldRefBase<T> {
|
|
13
|
+
/** Phantom — the decoded app-value type this ref projects to. Never present at runtime. */
|
|
14
|
+
readonly [REF_VALUE]: T;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The app-value type carried by a field ref (`never` if it isn't one). */
|
|
18
|
+
export type RefValue<R> = R extends FieldRefBase<infer T> ? T : never;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Brand a driver's field-ref implementation with the neutral {@link FieldRefBase} carrier, so `Project`
|
|
22
|
+
* can read its value back out. The phantom symbol is module-private on purpose (drivers can't forge it),
|
|
23
|
+
* so this is the one sanctioned bridge — a runtime identity that saves every driver an `as unknown as`
|
|
24
|
+
* cast. `impl`'s type is inferred; `T` (the carried value type) defaults to `unknown` because refs are
|
|
25
|
+
* untyped at runtime — the per-field type is supplied by the driver's `Row` mapping. Usage:
|
|
26
|
+
* `return brandRef({ eq, neq, gte, … });`.
|
|
27
|
+
*/
|
|
28
|
+
export function brandRef<I extends object, T = unknown>(
|
|
29
|
+
impl: I,
|
|
30
|
+
): I & FieldRefBase<T> {
|
|
31
|
+
return impl as I & FieldRefBase<T>;
|
|
32
|
+
}
|
package/src/query.ts
ADDED
package/src/secrets.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Secret references for secret-bearing DDL (e.g. SurrealDB `DEFINE ACCESS` keys). A `SecretRef` is an
|
|
2
|
+
// authoring-time PLACEHOLDER — it never carries the secret value. The value is resolved at APPLY time by
|
|
3
|
+
// the CLI through a `SecretProvider` and handed to the database as a BOUND PARAMETER (never spliced into
|
|
4
|
+
// the DDL string), so secrets stay out of the schema source, the snapshot, and the migration files.
|
|
5
|
+
//
|
|
6
|
+
// key: env("JWT_SECRET") // resolved from process.env at apply
|
|
7
|
+
// key: secret("jwt/signing-key") // resolved from the configured SecretProvider at apply
|
|
8
|
+
//
|
|
9
|
+
// The clause carrying a `SecretRef` is marked `writeOnly` in the IR (diff-excluded + snapshot-omitted),
|
|
10
|
+
// so a redacted secret never reads as drift; because the diff can't see the value, rotation is the
|
|
11
|
+
// explicit `apply --rotate-keys` (it can't be auto-detected).
|
|
12
|
+
|
|
13
|
+
/** An author-time reference to a secret, resolved to its value at apply time — never the value itself. */
|
|
14
|
+
export interface SecretRef {
|
|
15
|
+
/** `env` → resolved from `process.env`; `secret` → resolved from the configured {@link SecretProvider}. */
|
|
16
|
+
readonly kind: "env" | "secret";
|
|
17
|
+
/** The environment-variable / secret name to resolve at apply. */
|
|
18
|
+
readonly name: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Bind a value to an environment variable, resolved at apply from `process.env[name]`. */
|
|
22
|
+
export function env(name: string): SecretRef {
|
|
23
|
+
return { kind: "env", name };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Bind a value to a named secret, resolved at apply from the configured {@link SecretProvider}. */
|
|
27
|
+
export function secret(name: string): SecretRef {
|
|
28
|
+
return { kind: "secret", name };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Runtime guard: is `v` a {@link SecretRef} (vs a raw `string` literal key)? */
|
|
32
|
+
export function isSecretRef(v: unknown): v is SecretRef {
|
|
33
|
+
if (typeof v !== "object" || v === null) return false;
|
|
34
|
+
const r = v as Partial<SecretRef>;
|
|
35
|
+
return (
|
|
36
|
+
(r.kind === "env" || r.kind === "secret") && typeof r.name === "string"
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolves {@link SecretRef}s to their values at apply time. Pluggable: the default
|
|
42
|
+
* {@link envSecretProvider} reads every ref from `process.env`; swap it for a vault / file source by
|
|
43
|
+
* passing a custom provider to the apply layer. The resolved value is handed to the DB as a BOUND
|
|
44
|
+
* PARAMETER — never string-spliced into the DDL.
|
|
45
|
+
*/
|
|
46
|
+
export interface SecretProvider {
|
|
47
|
+
resolve(ref: SecretRef): string | Promise<string>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Default provider: resolves every {@link SecretRef} from `process.env[ref.name]`; throws if unset. */
|
|
51
|
+
export const envSecretProvider: SecretProvider = {
|
|
52
|
+
resolve(ref: SecretRef): string {
|
|
53
|
+
const value = process.env[ref.name];
|
|
54
|
+
if (value === undefined) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`better-schemic: secret ${ref.kind}(${JSON.stringify(ref.name)}) is not set in the environment`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return value;
|
|
60
|
+
},
|
|
61
|
+
};
|
package/src/seed.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The context the seed runner hands to each seed as its second argument — a small filesystem helper
|
|
3
|
+
* scoped to the seed's own directory, so a seed can load supporting files (raw `.surql`/`.sql`, JSON,
|
|
4
|
+
* …) without an `import … with { type: "text" }` declaration or any `import.meta.url` path juggling.
|
|
5
|
+
*
|
|
6
|
+
* A driver types its `defineSeed(fn)` helper as `(db: Conn, ctx: SeedContext) => …`; the connection is
|
|
7
|
+
* the driver's own type, this context is dialect-neutral.
|
|
8
|
+
*/
|
|
9
|
+
export interface SeedContext {
|
|
10
|
+
/** Absolute path of the directory containing the running seed. */
|
|
11
|
+
readonly dir: string;
|
|
12
|
+
/** Read a supporting file (resolved relative to {@link SeedContext.dir}) as a UTF-8 string. */
|
|
13
|
+
file(name: string): string;
|
|
14
|
+
}
|
package/src/testing.ts
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
// A shared DRIVER CONFORMANCE suite — the runtime contract a `@better-schemic/<driver>` must satisfy, asserted
|
|
2
|
+
// with `bun:test`. Each driver runs it against its own authoring surface:
|
|
3
|
+
//
|
|
4
|
+
// import { describeDriverConformance } from "@better-schemic/core/testing";
|
|
5
|
+
// import { defineTable, s, surrealDriver } from "@better-schemic/surrealdb";
|
|
6
|
+
// describeDriverConformance({ name: "surrealdb", s, driver: surrealDriver, defineEntity: defineTable });
|
|
7
|
+
//
|
|
8
|
+
// WHY a test, not a type: the zod drop-in builders (`s.string()` = `new <D>Field(z.string())`) are
|
|
9
|
+
// mechanically identical across drivers, but TypeScript has NO higher-kinded types, so a generic core
|
|
10
|
+
// factory can't preserve each driver's field type (it collapses to the base, dropping `$`-methods).
|
|
11
|
+
// Each driver therefore hand-authors its drop-ins, and "`s` is a Zod SUPERSET" is enforceable only at
|
|
12
|
+
// runtime. This suite is that enforcement.
|
|
13
|
+
//
|
|
14
|
+
// It DUCK-TYPES fields (a field is "something with a `.schema` that is a Zod type") rather than using
|
|
15
|
+
// `instanceof SFieldBase` — a driver may extend its own copy of the base, so identity checks are unsafe.
|
|
16
|
+
|
|
17
|
+
import { describe, expect, test } from "bun:test";
|
|
18
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import type * as z from "zod";
|
|
21
|
+
import { type Driver, driverNames, getDriver } from "./driver/driver";
|
|
22
|
+
import { emitKinds, type KindRegistry, lowerSchema } from "./kind";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The driver's authoring namespace (`s`) — a bag of field builders, some NESTED (e.g.
|
|
26
|
+
* `s.iso.{date,time,datetime,duration}`). Intentionally loose: the suite duck-types fields and
|
|
27
|
+
* enforces the real contract at RUNTIME, not via this type (see the header note), so a precise
|
|
28
|
+
* "function-or-nested-namespace" shape would only fight the internal `s.<key>()` calls for no gain.
|
|
29
|
+
*/
|
|
30
|
+
// biome-ignore lint/suspicious/noExplicitAny: a driver's `s` is dialect-specific + may nest; runtime-duck-typed.
|
|
31
|
+
type Authoring = Record<string, any>;
|
|
32
|
+
|
|
33
|
+
export interface DriverConformanceOptions {
|
|
34
|
+
/** The driver's registry name (e.g. `"surrealdb"`). */
|
|
35
|
+
name: string;
|
|
36
|
+
/** The driver's authoring namespace — the `s` each package exports. */
|
|
37
|
+
s: Authoring;
|
|
38
|
+
/** The driver under test (already registered by importing its package). */
|
|
39
|
+
driver: Driver<unknown>;
|
|
40
|
+
/**
|
|
41
|
+
* Authors the driver's primary fielded definable — a table, collection, node-type, … — from a name
|
|
42
|
+
* and a field shape. Used to lower a probe object through the pipeline. Drivers pass their own
|
|
43
|
+
* `define*` for this (e.g. `defineEntity: defineTable`); the suite stays shape-agnostic.
|
|
44
|
+
*/
|
|
45
|
+
// biome-ignore lint/suspicious/noExplicitAny: dialect-specific definable/shape types.
|
|
46
|
+
defineEntity: (name: string, shape: Record<string, any>) => any;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The canonical zod DROP-IN set every driver's `s` MUST expose — the structural Zod builders that make
|
|
51
|
+
* a `@better-schemic/<driver>` a drop-in for `z`. Each maps to the DB's natural representation (a driver may
|
|
52
|
+
* also offer richer native aliases, e.g. `text`/`varchar` alongside `string`). `object`/`array` nest a
|
|
53
|
+
* `literal` (present everywhere) so a missing `string` doesn't cascade into their tests.
|
|
54
|
+
*/
|
|
55
|
+
const DROP_INS: { key: string; build: (s: Authoring) => unknown }[] = [
|
|
56
|
+
{ key: "string", build: (s) => s.string() },
|
|
57
|
+
{ key: "number", build: (s) => s.number() },
|
|
58
|
+
{ key: "boolean", build: (s) => s.boolean() },
|
|
59
|
+
{ key: "date", build: (s) => s.date() },
|
|
60
|
+
{ key: "literal", build: (s) => s.literal("a") },
|
|
61
|
+
{ key: "enum", build: (s) => s.enum(["a", "b"]) },
|
|
62
|
+
{ key: "object", build: (s) => s.object({ inner: s.literal("a") }) },
|
|
63
|
+
{ key: "array", build: (s) => s.array(s.literal("a")) },
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
/** Value pairs that prove a scalar drop-in really carries the right Zod schema (unambiguous scalars only). */
|
|
67
|
+
const SCALAR_CHECKS: { key: string; valid: unknown; invalid: unknown }[] = [
|
|
68
|
+
{ key: "string", valid: "hello", invalid: 123 },
|
|
69
|
+
{ key: "number", valid: 123, invalid: "hello" },
|
|
70
|
+
{ key: "boolean", valid: true, invalid: "hello" },
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
/** Duck-typed: a field exposes a Zod `.schema`; a raw Zod type IS the schema. Throws if neither. */
|
|
74
|
+
function toSchema(v: unknown): z.ZodType {
|
|
75
|
+
const field = v as { schema?: unknown } | null;
|
|
76
|
+
if (field && isZod(field.schema)) return field.schema as z.ZodType;
|
|
77
|
+
if (isZod(v)) return v as z.ZodType;
|
|
78
|
+
throw new Error("expected a field (with a `.schema` Zod type) or a Zod type");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isZod(v: unknown): boolean {
|
|
82
|
+
return !!v && typeof (v as { safeParse?: unknown }).safeParse === "function";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Is `v` a driver field (has a `.schema` that is a Zod type)? */
|
|
86
|
+
function isField(v: unknown): boolean {
|
|
87
|
+
return isZod((v as { schema?: unknown } | null)?.schema);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Assert a `@better-schemic/<driver>` conforms to the Better-schemic driver contract: the Driver is registered with
|
|
92
|
+
* the IR pipeline + execution ops, and its `s` is a Zod-drop-in SUPERSET (the canonical drop-in set is
|
|
93
|
+
* present, carries the right schemas, composes through wrappers, and lowers to the portable IR).
|
|
94
|
+
*/
|
|
95
|
+
export function describeDriverConformance(
|
|
96
|
+
opts: DriverConformanceOptions,
|
|
97
|
+
): void {
|
|
98
|
+
const { name, s, driver, defineEntity } = opts;
|
|
99
|
+
|
|
100
|
+
describe(`driver conformance: ${name}`, () => {
|
|
101
|
+
describe("Driver contract", () => {
|
|
102
|
+
test("is registered under its name", () => {
|
|
103
|
+
expect(driverNames()).toContain(name);
|
|
104
|
+
expect(getDriver(name)).toBe(driver);
|
|
105
|
+
expect(driver.name).toBe(name);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("exposes a kind registry + the schema/execution ops", () => {
|
|
109
|
+
// Schema ops are generic over `registry`; the driver provides the fan-out + execution.
|
|
110
|
+
expect(driver.registry).toBeDefined();
|
|
111
|
+
expect(typeof driver.registry.entries).toBe("function");
|
|
112
|
+
expect(driver.registry.names().length).toBeGreaterThan(0);
|
|
113
|
+
for (const op of [
|
|
114
|
+
"explode",
|
|
115
|
+
"introspectAll",
|
|
116
|
+
"connect",
|
|
117
|
+
"apply",
|
|
118
|
+
"close",
|
|
119
|
+
] as const) {
|
|
120
|
+
expect(typeof driver[op]).toBe("function");
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("zod drop-in surface (s.* is a Zod superset)", () => {
|
|
126
|
+
for (const { key, build } of DROP_INS) {
|
|
127
|
+
test(`s.${key}() exists and returns a field`, () => {
|
|
128
|
+
expect(typeof s[key]).toBe("function");
|
|
129
|
+
const field = build(s);
|
|
130
|
+
expect(isField(field)).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
for (const { key, valid, invalid } of SCALAR_CHECKS) {
|
|
135
|
+
test(`s.${key}() carries a "${key}" Zod schema`, () => {
|
|
136
|
+
const schema = toSchema(s[key]());
|
|
137
|
+
expect(schema.safeParse(valid).success).toBe(true);
|
|
138
|
+
expect(schema.safeParse(invalid).success).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("Zod-clean codecs + wrappers", () => {
|
|
144
|
+
test("decode/encode delegate to the inner Zod schema", () => {
|
|
145
|
+
const field = s.string() as {
|
|
146
|
+
decode: (v: unknown) => unknown;
|
|
147
|
+
encode: (v: unknown) => unknown;
|
|
148
|
+
};
|
|
149
|
+
expect(field.decode("hi")).toBe("hi");
|
|
150
|
+
expect(field.encode("hi")).toBe("hi");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("wrappers preserve field-ness (optional/array compose)", () => {
|
|
154
|
+
const field = s.string() as {
|
|
155
|
+
optional: () => unknown;
|
|
156
|
+
array: () => unknown;
|
|
157
|
+
};
|
|
158
|
+
expect(isField(field.optional())).toBe(true);
|
|
159
|
+
expect(isField(field.array())).toBe(true);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("lowering (drop-in fields → kind registry)", () => {
|
|
164
|
+
test("an entity of drop-in fields explodes + lowers + emits, carrying every field", () => {
|
|
165
|
+
const shape: Record<string, unknown> = {};
|
|
166
|
+
for (const { key, build } of DROP_INS) shape[`f_${key}`] = build(s);
|
|
167
|
+
const entity = defineEntity("better_schemic_conformance_probe", shape);
|
|
168
|
+
|
|
169
|
+
// explode (authoring -> kinded definables) -> lowerSchema -> portable objects.
|
|
170
|
+
const portable = lowerSchema(
|
|
171
|
+
driver.registry,
|
|
172
|
+
driver.explode([entity], []),
|
|
173
|
+
);
|
|
174
|
+
// Kind-agnostic: the probe lowers to at least one object (its kind is the driver's own —
|
|
175
|
+
// `table`, `collection`, …); the per-field check below is what proves lowering is faithful.
|
|
176
|
+
expect(portable.length).toBeGreaterThan(0);
|
|
177
|
+
|
|
178
|
+
// The portable shape is the driver's own, but the emitted DDL is generic: every drop-in
|
|
179
|
+
// field name must appear in it (lowering + emit carried it through).
|
|
180
|
+
const ddl = emitKinds(driver.registry, portable).join("\n");
|
|
181
|
+
expect(ddl.length).toBeGreaterThan(0);
|
|
182
|
+
for (const { key } of DROP_INS) {
|
|
183
|
+
expect(ddl).toContain(`f_${key}`);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// --- Coverage reconcile -------------------------------------------------------------------------
|
|
191
|
+
//
|
|
192
|
+
// The shared, driver-AGNOSTIC guard that keeps a driver's `docs/COVERAGE.md` (prose discipline) honest
|
|
193
|
+
// against reality — closing the "done-vs-todo list silently drifts" gap. A driver declares its coverage
|
|
194
|
+
// as data (a KIND manifest + a FEATURE manifest) and this reconciles it against the LIVE facts: the
|
|
195
|
+
// neutral `registry.names()`/`.entries()` enumeration (so the registered-kind side CAN'T drift from the
|
|
196
|
+
// code) and the actual test titles (so a feature can't be marked done without a real test). The
|
|
197
|
+
// ENFORCEMENT lives here, in ONE place — a driver supplies only its manifest, so a fix propagates to
|
|
198
|
+
// every driver instead of drifting across three copies.
|
|
199
|
+
//
|
|
200
|
+
// DELIBERATELY NOT checked: "every kind defines `canonical()`". `KindEngine.canonical` is OPTIONAL by
|
|
201
|
+
// contract (it defaults to `emit(portable).join("\n")`), so a kind whose `emit` already IS its canonical
|
|
202
|
+
// form correctly omits it — requiring it here would false-fail a conformant driver. A driver that wants
|
|
203
|
+
// the stricter "all MY kinds define an explicit canonical" invariant can assert it in a local test.
|
|
204
|
+
|
|
205
|
+
/** Coverage status, mirroring the `docs/COVERAGE.md` checkbox: full round-trip / partial / not done. */
|
|
206
|
+
export type CoverageStatus = "x" | "~" | " ";
|
|
207
|
+
|
|
208
|
+
/** A registered KIND and the round-trip status it claims. */
|
|
209
|
+
export interface KindCoverage {
|
|
210
|
+
name: string;
|
|
211
|
+
status: CoverageStatus;
|
|
212
|
+
note?: string;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** A finer-grained FEATURE within a kind, and (when done) the test that proves it. */
|
|
216
|
+
export interface FeatureCoverage {
|
|
217
|
+
key: string;
|
|
218
|
+
kind: string;
|
|
219
|
+
status: CoverageStatus;
|
|
220
|
+
/** A substring of the test title that exercises this feature — REQUIRED when status is `x`. */
|
|
221
|
+
coveredBy?: string;
|
|
222
|
+
note?: string;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The live inputs a reconcile runs against (the pure form — no `bun:test`, no filesystem). */
|
|
226
|
+
export interface CoverageReconcileInput {
|
|
227
|
+
registry: KindRegistry;
|
|
228
|
+
kinds: KindCoverage[];
|
|
229
|
+
features: FeatureCoverage[];
|
|
230
|
+
/** Concatenated source of the driver's `*.test.ts` — real test titles are extracted from it. */
|
|
231
|
+
testSrc: string;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** One named check and the assertions it failed (empty = passed). */
|
|
235
|
+
export interface CoverageCheck {
|
|
236
|
+
name: string;
|
|
237
|
+
failures: string[];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The reconcile outcome: per-check breakdown + a flattened failure list for a single-assert test. */
|
|
241
|
+
export interface CoverageReconcileResult {
|
|
242
|
+
checks: CoverageCheck[];
|
|
243
|
+
failures: string[];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Extract the titles of the REAL, non-skipped `test(...)`/`it(...)` calls from concatenated test source.
|
|
248
|
+
* Matching against actual titles (rather than a raw `source.includes`) means a mention in a comment or an
|
|
249
|
+
* unrelated string literal can't count as coverage, and a `.skip`/`.todo` test can't satisfy a done claim.
|
|
250
|
+
*/
|
|
251
|
+
function extractTestTitles(src: string): string[] {
|
|
252
|
+
const re =
|
|
253
|
+
/\b(?:test|it)(\.[\w.]+)?\s*\(\s*(["'`])((?:\\.|(?!\2)[\s\S])*?)\2/g;
|
|
254
|
+
const titles: string[] = [];
|
|
255
|
+
for (const m of src.matchAll(re)) {
|
|
256
|
+
const modifier = m[1] ?? "";
|
|
257
|
+
if (/\.(?:skip|todo)\b/.test(modifier)) continue;
|
|
258
|
+
titles.push(m[3]);
|
|
259
|
+
}
|
|
260
|
+
return titles;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Reconcile a driver's declared coverage against the live registry + tests. PURE — returns a per-check
|
|
265
|
+
* breakdown; {@link describeCoverageReconcile} is the `bun:test` shell around it. See the section header
|
|
266
|
+
* for what is (and deliberately isn't) checked.
|
|
267
|
+
*/
|
|
268
|
+
export function reconcileCoverage(
|
|
269
|
+
input: CoverageReconcileInput,
|
|
270
|
+
): CoverageReconcileResult {
|
|
271
|
+
const { registry, kinds, features, testSrc } = input;
|
|
272
|
+
const registered = new Set(registry.names());
|
|
273
|
+
const checks: CoverageCheck[] = [];
|
|
274
|
+
|
|
275
|
+
// 1. Registered kinds EXACTLY equal the manifest, both directions — the neutral registry is the LHS,
|
|
276
|
+
// so registering a kind without listing it (or vice versa) fails by construction.
|
|
277
|
+
const declared = new Set(kinds.map((k) => k.name));
|
|
278
|
+
const kindsFailures: string[] = [];
|
|
279
|
+
for (const name of registered)
|
|
280
|
+
if (!declared.has(name))
|
|
281
|
+
kindsFailures.push(
|
|
282
|
+
`kind "${name}" is registered but missing from the manifest`,
|
|
283
|
+
);
|
|
284
|
+
for (const name of declared)
|
|
285
|
+
if (!registered.has(name))
|
|
286
|
+
kindsFailures.push(
|
|
287
|
+
`kind "${name}" is in the manifest but not registered`,
|
|
288
|
+
);
|
|
289
|
+
checks.push({
|
|
290
|
+
name: "registered kinds match the manifest",
|
|
291
|
+
failures: kindsFailures,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// 2. Every feature references a registered kind (referential integrity).
|
|
295
|
+
checks.push({
|
|
296
|
+
name: "every feature maps to a registered kind",
|
|
297
|
+
failures: features
|
|
298
|
+
.filter((f) => !registered.has(f.kind))
|
|
299
|
+
.map((f) => `feature "${f.key}" -> unknown kind "${f.kind}"`),
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// 3. Every DONE feature names a covering test that actually exists.
|
|
303
|
+
const titles = extractTestTitles(testSrc);
|
|
304
|
+
const coverFailures: string[] = [];
|
|
305
|
+
for (const f of features) {
|
|
306
|
+
if (f.status !== "x") continue;
|
|
307
|
+
if (!f.coveredBy) {
|
|
308
|
+
coverFailures.push(
|
|
309
|
+
`feature "${f.key}" is [x] but declares no coveredBy test`,
|
|
310
|
+
);
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (!titles.some((t) => t.includes(f.coveredBy as string)))
|
|
314
|
+
coverFailures.push(
|
|
315
|
+
`feature "${f.key}" coveredBy "${f.coveredBy}" — no matching (non-skipped) test title`,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
checks.push({
|
|
319
|
+
name: "every [x] feature names a covering test",
|
|
320
|
+
failures: coverFailures,
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// 4. No duplicate feature keys / kind entries (hygiene — a dup silently hides one side).
|
|
324
|
+
checks.push({
|
|
325
|
+
name: "no duplicate feature keys",
|
|
326
|
+
failures: duplicates(
|
|
327
|
+
features.map((f) => f.key),
|
|
328
|
+
"feature key",
|
|
329
|
+
),
|
|
330
|
+
});
|
|
331
|
+
checks.push({
|
|
332
|
+
name: "no duplicate kind entries",
|
|
333
|
+
failures: duplicates(
|
|
334
|
+
kinds.map((k) => k.name),
|
|
335
|
+
"kind entry",
|
|
336
|
+
),
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
return { checks, failures: checks.flatMap((c) => c.failures) };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Names appearing more than once, as failure messages. */
|
|
343
|
+
function duplicates(names: string[], label: string): string[] {
|
|
344
|
+
const seen = new Set<string>();
|
|
345
|
+
const dup: string[] = [];
|
|
346
|
+
for (const n of names) {
|
|
347
|
+
if (seen.has(n)) dup.push(`duplicate ${label} "${n}"`);
|
|
348
|
+
seen.add(n);
|
|
349
|
+
}
|
|
350
|
+
return dup;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Options for the `bun:test` reconcile shell. Supply `testDir` (read for you) OR a pre-read `testSrc`. */
|
|
354
|
+
export interface CoverageReconcileOptions {
|
|
355
|
+
/** Label for the describe block (typically the driver name). */
|
|
356
|
+
name?: string;
|
|
357
|
+
registry: KindRegistry;
|
|
358
|
+
kinds: KindCoverage[];
|
|
359
|
+
features: FeatureCoverage[];
|
|
360
|
+
/** The dir holding this driver's `*.test.ts`; the helper concatenates them to prove test coverage. */
|
|
361
|
+
testDir?: string;
|
|
362
|
+
/** Pre-read test source, if you'd rather gather it yourself (alternative to `testDir`). */
|
|
363
|
+
testSrc?: string;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Register the coverage reconcile as a `bun:test` block — one named `test(...)` per check, so CI reads
|
|
368
|
+
* granularly. A driver calls this from a single `*.test.ts` with its manifest + `testDir`:
|
|
369
|
+
*
|
|
370
|
+
* import { describeCoverageReconcile } from "@better-schemic/core/testing";
|
|
371
|
+
* import { registry } from "../src/kinds";
|
|
372
|
+
* import { KIND_MANIFEST, FEATURE_MANIFEST } from "./coverage-manifest";
|
|
373
|
+
* describeCoverageReconcile({ name: "sqlite", registry, kinds: KIND_MANIFEST,
|
|
374
|
+
* features: FEATURE_MANIFEST, testDir: import.meta.dir });
|
|
375
|
+
*/
|
|
376
|
+
export function describeCoverageReconcile(
|
|
377
|
+
opts: CoverageReconcileOptions,
|
|
378
|
+
): void {
|
|
379
|
+
const { name, registry, kinds, features } = opts;
|
|
380
|
+
const testSrc =
|
|
381
|
+
opts.testSrc ?? (opts.testDir ? readTestSrc(opts.testDir) : "");
|
|
382
|
+
describe(name ? `coverage reconcile: ${name}` : "coverage reconcile", () => {
|
|
383
|
+
for (const check of reconcileCoverage({
|
|
384
|
+
registry,
|
|
385
|
+
kinds,
|
|
386
|
+
features,
|
|
387
|
+
testSrc,
|
|
388
|
+
}).checks) {
|
|
389
|
+
test(check.name, () => {
|
|
390
|
+
expect(check.failures).toEqual([]);
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Concatenate every `*.test.ts` in `dir` (the source the covering-test check scans). */
|
|
397
|
+
function readTestSrc(dir: string): string {
|
|
398
|
+
return readdirSync(dir)
|
|
399
|
+
.filter((f) => f.endsWith(".test.ts"))
|
|
400
|
+
.map((f) => readFileSync(join(dir, f), "utf8"))
|
|
401
|
+
.join("\n");
|
|
402
|
+
}
|