@ogygie/generate 0.32.1 → 0.34.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/{chunk-KQ7RGIBV.js → chunk-UTS27UEI.js} +668 -216
- package/dist/cli/run.js +1 -1
- package/dist/emit/db.d.ts +0 -1
- package/dist/emit/hole-tests.d.ts +2 -0
- package/dist/emit/mssql/statements.d.ts +6 -2
- package/dist/emit/plan.d.ts +47 -2
- package/dist/generation-block.schema.d.ts +76 -2
- package/dist/hole-shape.d.ts +2 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +26 -5
- package/package.json +3 -3
|
@@ -423,6 +423,8 @@ var ReferenceSchema = z3.strictObject({
|
|
|
423
423
|
/** A nullable integer field of the read element carrying the count of references. */
|
|
424
424
|
countField: FieldNameSchema.optional()
|
|
425
425
|
});
|
|
426
|
+
var SharedTableSchema = z3.strictObject({ name: TableNameSchema, schema: SchemaNameSchema });
|
|
427
|
+
var SharedTableDeclarationSchema = z3.union([TableNameSchema, SharedTableSchema]);
|
|
426
428
|
var ContractsSchema = z3.strictObject({
|
|
427
429
|
/** Path of the schemas module, relative to the feature folder. */
|
|
428
430
|
module: z3.string().min(1),
|
|
@@ -435,9 +437,15 @@ var ContractsSchema = z3.strictObject({
|
|
|
435
437
|
key: FieldNameSchema.optional(),
|
|
436
438
|
slots: z3.record(IdentifierSchema, SlotSchema).optional(),
|
|
437
439
|
references: z3.record(IdentifierSchema, ReferenceSchema).optional(),
|
|
438
|
-
/** D32 precision (M3): tables of the product, outside the block, a view hole may read (`from`) — a referential projection, a shared table. Declared, never created nor written. */
|
|
439
|
-
sharedTables: z3.array(
|
|
440
|
+
/** D32 precision (M3): tables of the product, outside the block, a view hole may read (`from`) — a referential projection, a shared table. Declared, never created nor written. D60: declared WITH the schema of its rows, an aggregate hole reads it typed. */
|
|
441
|
+
sharedTables: z3.array(SharedTableDeclarationSchema).optional()
|
|
440
442
|
});
|
|
443
|
+
function sharedTableNameOf(declaration) {
|
|
444
|
+
return typeof declaration === "string" ? declaration : declaration.name;
|
|
445
|
+
}
|
|
446
|
+
function typedSharedTablesOf(contracts) {
|
|
447
|
+
return (contracts.sharedTables ?? []).filter((declaration) => typeof declaration !== "string");
|
|
448
|
+
}
|
|
441
449
|
var OrderDirectionSchema = z3.enum(["asc", "desc"]);
|
|
442
450
|
var OrderTupleSchema = z3.tuple([FieldNameSchema, OrderDirectionSchema]);
|
|
443
451
|
var OrderBySchema = z3.union([
|
|
@@ -582,12 +590,36 @@ var ViewHoleSchema = z3.strictObject({
|
|
|
582
590
|
value: z3.unknown()
|
|
583
591
|
})
|
|
584
592
|
});
|
|
593
|
+
var AggregateHoleSchema = z3.strictObject({
|
|
594
|
+
id: IdentifierSchema,
|
|
595
|
+
kind: z3.literal("aggregate"),
|
|
596
|
+
/** The fields of the input the hole reads — its first parameter is `Pick<input, reads>`; may be empty. */
|
|
597
|
+
reads: z3.array(FieldNameSchema).default([]),
|
|
598
|
+
/** Shared tables declared WITH a schema under contracts.sharedTables, whose rows the hole receives, typed. */
|
|
599
|
+
from: z3.array(TableNameSchema).optional(),
|
|
600
|
+
/** The root field of the read's output the hole writes. */
|
|
601
|
+
writes: FieldNameSchema,
|
|
602
|
+
/** The clock is the only effect an aggregate may declare: it receives its rows, it never reads the database. */
|
|
603
|
+
effects: z3.array(z3.literal("clock")).default([]),
|
|
604
|
+
examples: z3.strictObject({
|
|
605
|
+
/** One complete case per example: the input (its `reads`), the resource rows, the shared rows by table, the value the hole must answer. */
|
|
606
|
+
accept: z3.array(
|
|
607
|
+
z3.strictObject({
|
|
608
|
+
input: ExampleRecordSchema.optional(),
|
|
609
|
+
rows: z3.array(ExampleRecordSchema),
|
|
610
|
+
shared: z3.record(TableNameSchema, z3.array(ExampleRecordSchema)).optional(),
|
|
611
|
+
value: z3.unknown()
|
|
612
|
+
})
|
|
613
|
+
).min(1)
|
|
614
|
+
})
|
|
615
|
+
});
|
|
585
616
|
var HoleSchema = z3.discriminatedUnion("kind", [
|
|
586
617
|
ValidateHoleSchema,
|
|
587
618
|
TransformHoleSchema,
|
|
588
619
|
DeriveHoleSchema,
|
|
589
620
|
ProjectHoleSchema,
|
|
590
|
-
ViewHoleSchema
|
|
621
|
+
ViewHoleSchema,
|
|
622
|
+
AggregateHoleSchema
|
|
591
623
|
]);
|
|
592
624
|
function acceptExamplesOf(hole) {
|
|
593
625
|
return Array.isArray(hole.examples.accept) ? hole.examples.accept : [hole.examples.accept];
|
|
@@ -597,7 +629,8 @@ var HOLE_KIND_SERVES = {
|
|
|
597
629
|
transform: ["import"],
|
|
598
630
|
derive: ["create"],
|
|
599
631
|
project: ["read"],
|
|
600
|
-
view: ["read"]
|
|
632
|
+
view: ["read"],
|
|
633
|
+
aggregate: ["read"]
|
|
601
634
|
};
|
|
602
635
|
var GenerationBlockShapeSchema = z3.strictObject({
|
|
603
636
|
contracts: ContractsSchema,
|
|
@@ -793,12 +826,28 @@ function checkCrossReferences(block, issue) {
|
|
|
793
826
|
}
|
|
794
827
|
});
|
|
795
828
|
}
|
|
829
|
+
if (hole.kind === "aggregate") {
|
|
830
|
+
const typed = new Set(typedSharedTablesOf(block.contracts).map((table) => table.name));
|
|
831
|
+
for (const source of hole.from ?? []) {
|
|
832
|
+
if (!typed.has(source)) {
|
|
833
|
+
issue(["holes", index, "from"], `aggregate hole "${hole.id}" reads "${source}", which is not a shared table declared with a schema: declare it as contracts.sharedTables: [{ name: ${source}, schema: <RowSchema> }] (D60) \u2014 a bare name serves view holes only`);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
const reads = new Set(hole.reads);
|
|
837
|
+
hole.examples.accept.forEach((example, position) => {
|
|
838
|
+
const outside = Object.keys(example.input ?? {}).filter((key) => !reads.has(key));
|
|
839
|
+
if (outside.length > 0) issue(["holes", index, "examples"], `examples.accept[${position}].input reads ${outside.join(", ")}, which is not in reads (${hole.reads.join(", ") || "none"})`);
|
|
840
|
+
const unknownTables = Object.keys(example.shared ?? {}).filter((table) => !(hole.from ?? []).includes(table));
|
|
841
|
+
if (unknownTables.length > 0) issue(["holes", index, "examples"], `examples.accept[${position}].shared carries ${unknownTables.join(", ")}, which is not in from (${(hole.from ?? []).join(", ") || "none"})`);
|
|
842
|
+
if (!("value" in example)) issue(["holes", index, "examples"], `examples.accept[${position}] has no value: an aggregate example says what the hole answers for its rows`);
|
|
843
|
+
});
|
|
844
|
+
}
|
|
796
845
|
if (hole.kind === "view") {
|
|
797
846
|
const known = /* @__PURE__ */ new Set([
|
|
798
847
|
...block.contracts.table === void 0 ? [] : [tableNameOf(block.contracts.table)],
|
|
799
848
|
...Object.values(slots).map((slot) => tableNameOf(slot.table)),
|
|
800
849
|
...Object.values(references).map((reference) => tableNameOf(reference.table)),
|
|
801
|
-
...block.contracts.sharedTables ?? [],
|
|
850
|
+
...(block.contracts.sharedTables ?? []).map(sharedTableNameOf),
|
|
802
851
|
...viewNames
|
|
803
852
|
]);
|
|
804
853
|
known.delete(hole.name);
|
|
@@ -1209,6 +1258,7 @@ function vocabularyOf(operation, block, guards = EMPTY_GUARD_REGISTRY, contractV
|
|
|
1209
1258
|
namedValues.add(`${hole.writes} d\xE9riv\xE9`);
|
|
1210
1259
|
if (hole.error !== void 0) codes.set(hole.error, 400);
|
|
1211
1260
|
}
|
|
1261
|
+
if (hole?.kind === "aggregate") namedValues.add(`${hole.writes} d\xE9riv\xE9`);
|
|
1212
1262
|
}
|
|
1213
1263
|
return { cases, namedValues, phrases, codes, successStatus: SUCCESS_STATUS_BY_KIND[operation.kind] };
|
|
1214
1264
|
}
|
|
@@ -2313,6 +2363,14 @@ function checkBlockAgainstSchemas(input) {
|
|
|
2313
2363
|
}
|
|
2314
2364
|
if (model !== null) checkAdoptedShape(tableRefOf(reference.table), `contracts.references.${name}.table`, model, [], issue);
|
|
2315
2365
|
}
|
|
2366
|
+
const sharedModels = /* @__PURE__ */ new Map();
|
|
2367
|
+
(block.contracts.sharedTables ?? []).forEach((declaration, position) => {
|
|
2368
|
+
if (typeof declaration === "string") return;
|
|
2369
|
+
const model = exists(declaration.schema, `contracts.sharedTables.${position}.schema`);
|
|
2370
|
+
if (model === null) return;
|
|
2371
|
+
if (model.kind !== "object") issue(`contracts.sharedTables.${position}.schema`, `${declaration.schema} is not an object schema: a shared table's schema describes one row`, "Name a z.object() schema of one row of the shared table (D60).");
|
|
2372
|
+
else sharedModels.set(declaration.name, model);
|
|
2373
|
+
});
|
|
2316
2374
|
const supersedesMotif = block.motifs.appendOnlySupersedes;
|
|
2317
2375
|
if (supersedesMotif !== void 0 && resource !== null && resource.kind === "object") {
|
|
2318
2376
|
const missing = ["supersedes", "supersededBy"].filter((field) => {
|
|
@@ -2416,6 +2474,22 @@ function checkBlockAgainstSchemas(input) {
|
|
|
2416
2474
|
);
|
|
2417
2475
|
}
|
|
2418
2476
|
}
|
|
2477
|
+
if (hole.kind === "aggregate") {
|
|
2478
|
+
const outside = hole.reads.filter((field) => !inputFields.has(field));
|
|
2479
|
+
if (outside.length > 0) issue(`holes.${holeIndex}.reads`, `aggregate hole "${hole.id}" reads ${outside.join(", ")}, not field(s) of ${operation.input}`, "An aggregate hole reads fields of the input of the read it serves (D60).");
|
|
2480
|
+
if (inputFields.has(hole.writes)) issue(`holes.${holeIndex}.writes`, `aggregate hole "${hole.id}" writes "${hole.writes}", which ${operation.input} already carries: an echo of the input is served as is, nothing to compute`, "Write a field of the output the input does not carry (D60).");
|
|
2481
|
+
hole.examples.accept.forEach((example, position) => {
|
|
2482
|
+
const rowsOutside = [...new Set(example.rows.flatMap((row) => Object.keys(row).filter((key) => !resourceFields.has(key))))];
|
|
2483
|
+
if (rowsOutside.length > 0) issue(`holes.${holeIndex}.examples`, `examples.accept[${position}].rows carry ${rowsOutside.join(", ")}, not field(s) of ${block.contracts.resource ?? "the resource"}`, "The rows of an aggregate example are rows of the resource schema, partial allowed: the test completes them (D60).");
|
|
2484
|
+
for (const [table, sharedRows] of Object.entries(example.shared ?? {})) {
|
|
2485
|
+
const model = sharedModels.get(table);
|
|
2486
|
+
if (model === void 0) continue;
|
|
2487
|
+
const fields = new Set(fieldNamesOf(model));
|
|
2488
|
+
const sharedOutside = [...new Set(sharedRows.flatMap((row) => Object.keys(row).filter((key) => !fields.has(key))))];
|
|
2489
|
+
if (sharedOutside.length > 0) issue(`holes.${holeIndex}.examples`, `examples.accept[${position}].shared.${table} carries ${sharedOutside.join(", ")}, not field(s) of its schema`, "The shared rows of an aggregate example are rows of the shared table's schema (D60).");
|
|
2490
|
+
}
|
|
2491
|
+
});
|
|
2492
|
+
}
|
|
2419
2493
|
if (hole.kind === "derive" && resource !== null) {
|
|
2420
2494
|
if (!resourceFields.has(hole.writes)) {
|
|
2421
2495
|
issue(`holes.${holeIndex}.writes`, `derive hole "${hole.id}" writes "${hole.writes}", which is not a field of ${block.contracts.resource}`, "A derive hole writes a field of the resource (D32).");
|
|
@@ -2497,12 +2571,29 @@ function checkOutputMapping(operation, index, output, inputFields, resource, blo
|
|
|
2497
2571
|
const entries = Object.entries(output.fields);
|
|
2498
2572
|
if (operation.kind === "read") {
|
|
2499
2573
|
const collection = readCollectionOf(output, resource);
|
|
2574
|
+
const aggregates = (operation.holes ?? []).map((id) => block.holes.find((candidate) => candidate.id === id)).flatMap((hole) => hole?.kind === "aggregate" ? [hole] : []);
|
|
2575
|
+
const aggregateWrites = new Set(aggregates.map((hole) => hole.writes));
|
|
2576
|
+
for (const hole of aggregates) {
|
|
2577
|
+
if (!(hole.writes in output.fields)) {
|
|
2578
|
+
issue(`holes.${block.holes.indexOf(hole)}.writes`, `aggregate hole "${hole.id}" writes "${hole.writes}", which is not a root field of ${operation.output}`, "An aggregate hole writes a root field of the read's output (D60).");
|
|
2579
|
+
}
|
|
2580
|
+
}
|
|
2500
2581
|
if (collection === null) {
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2582
|
+
if (aggregates.length === 0) {
|
|
2583
|
+
issue(
|
|
2584
|
+
path,
|
|
2585
|
+
`${operation.output} must carry exactly one array whose element covers ${resourceLabel(resource)} \u2014 found ${entries.map(([name]) => name).join(", ")}`,
|
|
2586
|
+
"Shape a read output as { <collection>: z.array(ResourceView), ...echoed input fields, ...project fields } \u2014 or, for a report computed over the rows (an aggregate read, D60), carry no collection and write every other field with an aggregate hole: { id, kind: aggregate, reads: [input fields], writes: <field>, examples: { accept: [{ input, rows, value }] } }."
|
|
2587
|
+
);
|
|
2588
|
+
return;
|
|
2589
|
+
}
|
|
2590
|
+
const projectRootsOfReport = new Set(
|
|
2591
|
+
(operation.holes ?? []).map((id) => block.holes.find((candidate) => candidate.id === id)).flatMap((hole) => hole?.kind === "project" ? hole.writes.filter((write) => !write.includes("[]")) : [])
|
|
2505
2592
|
);
|
|
2593
|
+
const others = entries.filter(([name]) => !inputFields.has(name) && !aggregateWrites.has(name) && !projectRootsOfReport.has(name));
|
|
2594
|
+
if (others.length > 0) {
|
|
2595
|
+
issue(path, `${operation.output} carries ${others.map(([name]) => name).join(", ")}: neither an echo of the input nor written by an aggregate hole of "${operation.id}"`, "In an aggregate read (D60) every root field of the output is an echo of the input (same name) or the writes of an aggregate hole the operation lists: declare a hole { kind: aggregate, writes: <field>, \u2026 } for each computed field.");
|
|
2596
|
+
}
|
|
2506
2597
|
return;
|
|
2507
2598
|
}
|
|
2508
2599
|
const projectRoots = new Set(
|
|
@@ -2514,7 +2605,7 @@ function checkOutputMapping(operation, index, output, inputFields, resource, blo
|
|
|
2514
2605
|
const viewFields2 = new Set(
|
|
2515
2606
|
(operation.holes ?? []).map((id) => block.holes.find((candidate) => candidate.id === id)).flatMap((hole) => hole?.kind === "view" ? [hole.writes] : [])
|
|
2516
2607
|
);
|
|
2517
|
-
const rootOthers = entries.filter(([name]) => name !== collection.collection && !inputFields.has(name) && !projectRoots.has(name));
|
|
2608
|
+
const rootOthers = entries.filter(([name]) => name !== collection.collection && !inputFields.has(name) && !projectRoots.has(name) && !aggregateWrites.has(name));
|
|
2518
2609
|
if (rootOthers.length > 0) {
|
|
2519
2610
|
issue(path, `${operation.output} carries ${rootOthers.map(([name]) => name).join(", ")} at its root: neither an echo of the input nor a field a project hole writes`, "A read output's root holds the collection, echoes of the input and the fields of its project holes.");
|
|
2520
2611
|
}
|
|
@@ -3091,10 +3182,6 @@ function orderPlanOf(operation, schemas, resourceFields) {
|
|
|
3091
3182
|
const tuples = typeof operation.orderBy[0] === "string" ? [operation.orderBy] : operation.orderBy;
|
|
3092
3183
|
return { enumOrder: null, tuples: tuples.map(([field, direction]) => ({ field, direction })) };
|
|
3093
3184
|
}
|
|
3094
|
-
function tenantOf(plan) {
|
|
3095
|
-
if (plan.tenant === null) throw new Error("generation plan: this emitter needs a tenant column (target.tenancy column) \u2014 a target without tenant is emitted by the mssql emitters");
|
|
3096
|
-
return plan.tenant;
|
|
3097
|
-
}
|
|
3098
3185
|
function schemaNameOfModel(model, schemas, except) {
|
|
3099
3186
|
const wanted = JSON.stringify({ ...model, optional: false, nullable: model.nullable, examples: [] });
|
|
3100
3187
|
for (const [name, entry] of Object.entries(schemas)) {
|
|
@@ -3378,6 +3465,22 @@ function buildFeaturePlan(input) {
|
|
|
3378
3465
|
}
|
|
3379
3466
|
return null;
|
|
3380
3467
|
};
|
|
3468
|
+
const sharedTables = typedSharedTablesOf(block.contracts).map((declaration) => {
|
|
3469
|
+
const model = modelNamed2(schemas, declaration.schema);
|
|
3470
|
+
const fields = objectFields(model, declaration.schema);
|
|
3471
|
+
const sharedColumns = [...tenantColumns(), ...Object.entries(fields).map(([name, fieldModel2]) => columnOf(name, fieldModel2, void 0))];
|
|
3472
|
+
const typeName = typeNameOfSchema(declaration.schema);
|
|
3473
|
+
return {
|
|
3474
|
+
sqlName: declaration.name,
|
|
3475
|
+
tsName: camelOfSnake(declaration.name),
|
|
3476
|
+
schemaName: declaration.schema,
|
|
3477
|
+
typeName,
|
|
3478
|
+
rowType: `${typeName}Row`,
|
|
3479
|
+
model,
|
|
3480
|
+
fields: Object.entries(fields).map(([name, fieldModel2]) => ({ name, model: fieldModel2, dtoExpression: dtoExpressionOf(name, fieldModel2, sharedColumns.find((column) => column.key === name)?.sqlType, generation.target.database) })),
|
|
3481
|
+
columns: sharedColumns
|
|
3482
|
+
};
|
|
3483
|
+
});
|
|
3381
3484
|
const holes = block.holes.map((hole) => {
|
|
3382
3485
|
const carrier = block.operations.find((operation) => isHttpOperation(operation) && (operation.holes ?? []).includes(hole.id));
|
|
3383
3486
|
invariant(carrier !== void 0 && isHttpOperation(carrier), `hole ${hole.id} has no carrier`);
|
|
@@ -3424,6 +3527,29 @@ function buildFeaturePlan(input) {
|
|
|
3424
3527
|
if (hole.kind === "project") {
|
|
3425
3528
|
return { kind: "project", id: hole.id, hole, reads: hole.reads, writes: hole.writes, outputTypeName: typeNameOfSchema(carrier.output), accepts: hole.examples.accept };
|
|
3426
3529
|
}
|
|
3530
|
+
if (hole.kind === "aggregate") {
|
|
3531
|
+
const outputModel = modelNamed2(schemas, carrier.output);
|
|
3532
|
+
const writesModel2 = outputModel.kind === "object" ? outputModel.fields[hole.writes] : void 0;
|
|
3533
|
+
invariant(writesModel2 !== void 0, `aggregate hole ${hole.id} writes an unknown output field`);
|
|
3534
|
+
const from = (hole.from ?? []).map((name) => {
|
|
3535
|
+
const table = sharedTables.find((candidate) => candidate.sqlName === name);
|
|
3536
|
+
invariant(table !== void 0, `aggregate hole ${hole.id} reads an undeclared shared table ${name}`);
|
|
3537
|
+
return table;
|
|
3538
|
+
});
|
|
3539
|
+
return {
|
|
3540
|
+
kind: "aggregate",
|
|
3541
|
+
id: hole.id,
|
|
3542
|
+
hole,
|
|
3543
|
+
reads: hole.reads,
|
|
3544
|
+
inputTypeName: typeNameOfSchema(carrier.input),
|
|
3545
|
+
resourceTypeName: resourceType,
|
|
3546
|
+
outputTypeName: typeNameOfSchema(carrier.output),
|
|
3547
|
+
writes: hole.writes,
|
|
3548
|
+
writesModel: writesModel2,
|
|
3549
|
+
from,
|
|
3550
|
+
accepts: hole.examples.accept.map((example) => ({ input: example.input ?? {}, rows: example.rows, shared: example.shared ?? {}, value: example.value }))
|
|
3551
|
+
};
|
|
3552
|
+
}
|
|
3427
3553
|
const element = elementOf(carrier);
|
|
3428
3554
|
invariant(element !== null && element.model.kind === "object", `view hole ${hole.id}: the read has no element`);
|
|
3429
3555
|
const writesModel = element.model.fields[hole.writes];
|
|
@@ -3473,6 +3599,9 @@ function buildFeaturePlan(input) {
|
|
|
3473
3599
|
else if (operation.kind === "import" && model.kind === "number") outputField = name;
|
|
3474
3600
|
else if (name in inputFields) echoFields.push(name);
|
|
3475
3601
|
}
|
|
3602
|
+
const aggregateIds = new Set(block.holes.filter((hole) => hole.kind === "aggregate").map((hole) => hole.id));
|
|
3603
|
+
const report2 = operation.kind === "read" && element === null && (operation.holes ?? []).some((id) => aggregateIds.has(id));
|
|
3604
|
+
if (report2) outputField = "";
|
|
3476
3605
|
invariant(outputField !== void 0, `${operation.output} has no mappable field`);
|
|
3477
3606
|
const opHoles = (operation.holes ?? []).map((id) => {
|
|
3478
3607
|
const hole = holeById.get(id);
|
|
@@ -3513,6 +3642,8 @@ function buildFeaturePlan(input) {
|
|
|
3513
3642
|
deriveHoles: opHoles.filter((hole) => hole.kind === "derive"),
|
|
3514
3643
|
projectHoles: opHoles.filter((hole) => hole.kind === "project"),
|
|
3515
3644
|
viewHoles: opHoles.filter((hole) => hole.kind === "view"),
|
|
3645
|
+
aggregateHoles: opHoles.filter((hole) => hole.kind === "aggregate"),
|
|
3646
|
+
report: report2,
|
|
3516
3647
|
order: orderPlanOf(operation, schemas, resourceFields),
|
|
3517
3648
|
limit: operation.kind === "read" ? operation.limit ?? null : null,
|
|
3518
3649
|
range: operation.kind === "read" ? operation.range ?? null : null,
|
|
@@ -3585,6 +3716,7 @@ function buildFeaturePlan(input) {
|
|
|
3585
3716
|
key: keyField === null || keyColumn === void 0 || keyUniqueName === null ? null : { field: keyField, column: keyColumn, uniqueName: keyUniqueName },
|
|
3586
3717
|
slots,
|
|
3587
3718
|
references,
|
|
3719
|
+
sharedTables,
|
|
3588
3720
|
signed: block.motifs.signedWrite ?? null,
|
|
3589
3721
|
unique,
|
|
3590
3722
|
referenced: (() => {
|
|
@@ -3724,6 +3856,19 @@ function holeSignatureLines(hole, plan) {
|
|
|
3724
3856
|
];
|
|
3725
3857
|
}
|
|
3726
3858
|
if (hole.kind === "view") return [];
|
|
3859
|
+
if (hole.kind === "aggregate") {
|
|
3860
|
+
const picks = hole.reads.length === 0 ? "Record<never, never>" : `Pick<${hole.inputTypeName}, ${hole.reads.map((field) => quote(field)).join(" | ")}>`;
|
|
3861
|
+
const shared = hole.from.length === 0 ? [] : [` shared: { ${hole.from.map((table) => `${table.tsName}: readonly ${table.typeName}[]`).join("; ")} },`];
|
|
3862
|
+
return [
|
|
3863
|
+
`// @hole ${hole.id}`,
|
|
3864
|
+
`${prefix} ${hole.id}(`,
|
|
3865
|
+
` input: ${picks},`,
|
|
3866
|
+
` rows: readonly ${hole.resourceTypeName}[],`,
|
|
3867
|
+
...shared,
|
|
3868
|
+
...effects,
|
|
3869
|
+
`): ${wrapResult(`${hole.outputTypeName}[${quote(hole.writes)}]`)} {`
|
|
3870
|
+
];
|
|
3871
|
+
}
|
|
3727
3872
|
return [
|
|
3728
3873
|
`// @hole ${hole.id}`,
|
|
3729
3874
|
`${prefix} ${hole.id}(input: { text: string }${effects.length === 0 ? "" : `, ${effects.map((line) => line.trim().replace(/,$/u, "")).join(", ")}`}): ${wrapResult(`HoleResult<${hole.rowTypeName}[], ${quote(hole.errorCode)}>`)} {`
|
|
@@ -3778,8 +3923,14 @@ function emitLogic(plan, existingLogic = null) {
|
|
|
3778
3923
|
for (const hole of plan.holes) {
|
|
3779
3924
|
if (hole.kind === "validate" || hole.kind === "derive") inputTypes.add(hole.inputTypeName);
|
|
3780
3925
|
if (hole.kind === "project") inputTypes.add(hole.outputTypeName);
|
|
3926
|
+
if (hole.kind === "aggregate") {
|
|
3927
|
+
if (hole.reads.length > 0) inputTypes.add(hole.inputTypeName);
|
|
3928
|
+
inputTypes.add(hole.resourceTypeName);
|
|
3929
|
+
inputTypes.add(hole.outputTypeName);
|
|
3930
|
+
for (const table of hole.from) inputTypes.add(table.typeName);
|
|
3931
|
+
}
|
|
3781
3932
|
}
|
|
3782
|
-
const needsClock = plan.holes.some((hole) => (hole.kind === "validate" || hole.kind === "derive" || hole.kind === "transform") && hole.hole.effects.includes("clock"));
|
|
3933
|
+
const needsClock = plan.holes.some((hole) => (hole.kind === "validate" || hole.kind === "derive" || hole.kind === "transform" || hole.kind === "aggregate") && hole.hole.effects.includes("clock"));
|
|
3783
3934
|
const needsDb = plan.holes.some((hole) => (hole.kind === "validate" || hole.kind === "derive" || hole.kind === "transform") && hole.hole.effects.includes("db"));
|
|
3784
3935
|
const hasProject = plan.holes.some((hole) => hole.kind === "project");
|
|
3785
3936
|
const sections = [];
|
|
@@ -3808,7 +3959,8 @@ function emitLogic(plan, existingLogic = null) {
|
|
|
3808
3959
|
needsDb ? plan.tenant === null ? `import type { ${plan.databasePort?.typeName ?? "Database"} } from ${quote(plan.databasePort?.importPath ?? "../../db.js")};` : 'import type { TenantDb } from "@ogygie/core/tenant";' : null,
|
|
3809
3960
|
hasProject ? `import type { ${plan.context.typeName} } from ${quote(plan.context.importPath)};` : null,
|
|
3810
3961
|
inputTypes.size === 0 ? null : `import type { ${[...inputTypes].sort().join(", ")} } from "./schemas.js";`,
|
|
3811
|
-
|
|
3962
|
+
// A hole answering a HoleResult needs accept and refuse; an aggregate or a project answers a value — todo alone (D60).
|
|
3963
|
+
plan.holes.some((hole) => hole.kind === "validate" || hole.kind === "derive" || hole.kind === "transform") ? 'import { accept, refuse, todo, type HoleResult } from "./holes.runtime.gen.js";' : 'import { todo } from "./holes.runtime.gen.js";',
|
|
3812
3964
|
...hasProject ? [
|
|
3813
3965
|
"",
|
|
3814
3966
|
"/** What a project hole may read of the caller: the guard's context without its database (D32). */",
|
|
@@ -3895,13 +4047,23 @@ function drizzleTableLines(table, builders, tablesByName) {
|
|
|
3895
4047
|
function drizzleViewLines(view, builders) {
|
|
3896
4048
|
for (const column of view.columns) builders.add(builderOf(column));
|
|
3897
4049
|
return [
|
|
3898
|
-
`/** READ-ONLY mapping of the view ${view.name} (hole ${view.id}, D32): the SQL lives in the migration, this table shape is what
|
|
4050
|
+
`/** READ-ONLY mapping of the view ${view.name} (hole ${view.id}, D32): the SQL lives in the migration, this table shape is what the repository's facade reads; nothing is ever written through it. */`,
|
|
3899
4051
|
`export const ${view.tsName} = pgTable(${quote(view.name)}, {`,
|
|
3900
4052
|
...view.columns.map((column) => ` ${column.key}: ${column.drizzle},`),
|
|
3901
4053
|
"});",
|
|
3902
4054
|
`export type ${view.rowType} = typeof ${view.tsName}.$inferSelect;`
|
|
3903
4055
|
];
|
|
3904
4056
|
}
|
|
4057
|
+
function drizzleSharedLines(table, builders) {
|
|
4058
|
+
for (const column of table.columns) builders.add(builderOf(column));
|
|
4059
|
+
return [
|
|
4060
|
+
`/** READ-ONLY mapping of the shared table ${table.sqlName} (D60): a table of the product outside this feature, read by its aggregate holes through the repository's facade; never created nor written here. */`,
|
|
4061
|
+
`export const ${table.tsName} = pgTable(${quote(table.sqlName)}, {`,
|
|
4062
|
+
...table.columns.map((column) => ` ${column.key}: ${column.drizzle},`),
|
|
4063
|
+
"});",
|
|
4064
|
+
`export type ${table.rowType} = typeof ${table.tsName}.$inferSelect;`
|
|
4065
|
+
];
|
|
4066
|
+
}
|
|
3905
4067
|
function tablesOf(plan) {
|
|
3906
4068
|
return [plan.table, ...plan.slots.map((slot) => slot.table), ...plan.references.flatMap((reference) => reference.table === null ? [] : [reference.table])];
|
|
3907
4069
|
}
|
|
@@ -3945,9 +4107,10 @@ function emitDbSchema(plan) {
|
|
|
3945
4107
|
const sections = [];
|
|
3946
4108
|
for (const table of tables) sections.push("", ...drizzleTableLines(table, builders, tablesByName));
|
|
3947
4109
|
for (const view of views) sections.push("", ...drizzleViewLines(view, builders));
|
|
4110
|
+
for (const table of plan.sharedTables) sections.push("", ...drizzleSharedLines(table, builders));
|
|
3948
4111
|
const needsSql = tables.some((table) => table.uniques.some((unique) => unique.where !== null));
|
|
3949
4112
|
const intent = wrap(
|
|
3950
|
-
`Drizzle tables owned by the ${plan.featureId} feature, derived from ${plan.resource.schemaName} (${plan.specPath}, section G\xE9n\xE9ration): tenant-scoped by ${
|
|
4113
|
+
`Drizzle tables owned by the ${plan.featureId} feature, derived from ${plan.resource.schemaName} (${plan.specPath}, section G\xE9n\xE9ration): ${plan.tenant === null ? "without tenant column (target.tenancy none, D33: the product is single-tenant, the repository reads whole tables through plainDb)" : `tenant-scoped by ${plan.tenant.column} (TS key \`${plan.tenant.columnKey}\`, the column tenantDb injects and filters)`}.${plan.key === null ? "" : ` The business key ${plan.key.field} is unique per tenant (${plan.key.uniqueName}), the target of every slot and reference foreign key.`}${plan.slots.length === 0 ? "" : ` Slot tables: ${plan.slots.map((slot) => slot.table.sqlName).join(", ")} (cascade with the resource row).`}${plan.references.some((reference) => reference.table !== null) ? ` Reference tables: ${plan.references.flatMap((reference) => reference.table === null ? [] : [reference.table.sqlName]).join(", ")} (a referenced row cannot be deleted).` : ""}${views.length === 0 ? "" : ` Views ${views.map((view) => view.name).join(", ")} are read-only mappings: their SQL is in the migration.`}${plan.appendOnly === null ? "" : ` The append-only key (${plan.appendOnly.key.join(", ")}) is unique among CURRENT rows (superseded_by is null).`} SQL migration: ${plan.migrationPath}.`
|
|
3951
4114
|
);
|
|
3952
4115
|
return fileOf(
|
|
3953
4116
|
tsGeneratedHeader(plan.generatorVersion),
|
|
@@ -3975,6 +4138,8 @@ function createTableStatements(table) {
|
|
|
3975
4138
|
for (const unique of table.uniques.filter((candidate) => candidate.where !== null)) {
|
|
3976
4139
|
statements.push(`create unique index ${quote(unique.name)} on ${quote(table.sqlName)} (${unique.columns.map(quote).join(", ")}) where ${unique.where ?? ""};`);
|
|
3977
4140
|
}
|
|
4141
|
+
const tenantColumn = table.columns.find((column) => column.model === null && column.key !== "id");
|
|
4142
|
+
if (tenantColumn === void 0) return statements;
|
|
3978
4143
|
statements.push(
|
|
3979
4144
|
"",
|
|
3980
4145
|
`alter table ${quote(table.sqlName)} enable row level security;`,
|
|
@@ -3993,7 +4158,7 @@ function emitMigration(plan, existingMigration = null) {
|
|
|
3993
4158
|
const bodies = viewBodiesOf(existingMigration);
|
|
3994
4159
|
const adopted = tablesOf(plan).filter((candidate) => candidate.mode === "adopted");
|
|
3995
4160
|
const intent = wrap(
|
|
3996
|
-
`${plan.featureId} feature: the ${plan.table.sqlName} table${tables.length > 1 ? ` and ${tables.slice(1).map((table) => table.sqlName).join(", ")}` : ""}, tenant-scoped by ${
|
|
4161
|
+
`${plan.featureId} feature: the ${plan.table.sqlName} table${tables.length > 1 ? ` and ${tables.slice(1).map((table) => table.sqlName).join(", ")}` : ""}, ${plan.tenant === null ? "without tenant column (target.tenancy none, D33)" : `tenant-scoped by ${plan.tenant.column}`} (derived from ${plan.resource.schemaName}, ${plan.specPath}).${adopted.length === 0 ? "" : ` ${adopted.map((table) => table.sqlName).join(", ")} ${adopted.length > 1 ? "are ADOPTED tables" : "is an ADOPTED table"} (D36): declared in the block, created by the product, altered here within the closed list only.`} ${plan.tenant === null ? "No tenant column, no row-level security: the product is single-tenant (D33)" : `Same rails as the socle: foreign key to ${plan.tenant.table}, Row-Level Security on every table (AGENTS.md \xA7Multi-tenant 3 \u2014 second rampart, tenantDb remains the first)`}${plan.appendOnly === null ? "" : ", plus the append-only motif: a partial unique index on the current rows and a DEFERRABLE superseded_by foreign key, so the old row is marked before the new one is inserted"}${views.length === 0 ? "." : `. The views ${views.map((view) => view.name).join(", ")} are HOLES (D32): their SQL stands between the @hole markers below, written by a human, kept at every regeneration; everything else in this file is generated.`}`
|
|
3997
4162
|
).map((line, index) => `-- ${index === 0 ? "@intent " : ""}${line}`);
|
|
3998
4163
|
const statements = [];
|
|
3999
4164
|
for (const table of tables) statements.push(...statements.length === 0 ? [] : [""], ...createTableStatements(table));
|
|
@@ -4011,6 +4176,160 @@ function emitMigration(plan, existingMigration = null) {
|
|
|
4011
4176
|
return fileOf(sqlGeneratedHeader(plan.generatorVersion), ...intent, "", ...statements);
|
|
4012
4177
|
}
|
|
4013
4178
|
|
|
4179
|
+
// src/emit/hole-tests.ts
|
|
4180
|
+
function deepOverlay(base, overlay) {
|
|
4181
|
+
if (Array.isArray(overlay)) {
|
|
4182
|
+
const baseItems = Array.isArray(base) ? base : [];
|
|
4183
|
+
return overlay.map((item, index) => deepOverlay(baseItems[index] ?? baseItems[0], item));
|
|
4184
|
+
}
|
|
4185
|
+
if (overlay !== null && typeof overlay === "object") {
|
|
4186
|
+
const result = base !== null && typeof base === "object" && !Array.isArray(base) ? { ...base } : {};
|
|
4187
|
+
for (const [key, value] of Object.entries(overlay)) result[key] = deepOverlay(result[key], value);
|
|
4188
|
+
return result;
|
|
4189
|
+
}
|
|
4190
|
+
return overlay;
|
|
4191
|
+
}
|
|
4192
|
+
function sampleUnder(model, overlay) {
|
|
4193
|
+
if (model.kind === "object" && overlay !== null && typeof overlay === "object" && !Array.isArray(overlay)) {
|
|
4194
|
+
const given = overlay;
|
|
4195
|
+
const value = {};
|
|
4196
|
+
for (const [name, field] of Object.entries(model.fields)) {
|
|
4197
|
+
if (name in given) value[name] = sampleUnder(field, given[name]);
|
|
4198
|
+
else if (!field.optional) {
|
|
4199
|
+
const sample = sampleOf(field, [name]);
|
|
4200
|
+
value[name] = sample.ok ? sample.value : null;
|
|
4201
|
+
}
|
|
4202
|
+
}
|
|
4203
|
+
return value;
|
|
4204
|
+
}
|
|
4205
|
+
if (model.kind === "array" && Array.isArray(overlay)) return overlay.map((item) => sampleUnder(model.element, item));
|
|
4206
|
+
return overlay;
|
|
4207
|
+
}
|
|
4208
|
+
function projectInputOf(plan, carrier, partial) {
|
|
4209
|
+
void plan;
|
|
4210
|
+
return sampleUnder(carrier.outputModel, partial);
|
|
4211
|
+
}
|
|
4212
|
+
var HOLE_TESTS_CLOCK_INSTANT = "2026-01-01T00:00:00.000Z";
|
|
4213
|
+
function holeDeclaresClock(hole) {
|
|
4214
|
+
return (hole.hole.effects ?? []).includes("clock");
|
|
4215
|
+
}
|
|
4216
|
+
function constantNameOf(identifier) {
|
|
4217
|
+
return identifier.replace(/([a-z0-9])([A-Z])/gu, "$1_$2").toUpperCase();
|
|
4218
|
+
}
|
|
4219
|
+
function emitHoleTests(plan) {
|
|
4220
|
+
const blocks = [];
|
|
4221
|
+
const testedHoles = plan.holes.filter((hole) => hole.kind !== "view");
|
|
4222
|
+
const needsOutputs = /* @__PURE__ */ new Set();
|
|
4223
|
+
const needsExamples = /* @__PURE__ */ new Set();
|
|
4224
|
+
const needsClock = testedHoles.some((hole) => hole.kind !== "project" && holeDeclaresClock(hole));
|
|
4225
|
+
for (const hole of testedHoles) {
|
|
4226
|
+
const withEffects = (argument) => hole.kind !== "project" && holeDeclaresClock(hole) ? `${argument}, CLOCK` : argument;
|
|
4227
|
+
const call = (argument) => isAsyncHole(hole) ? `await ${hole.id}(${withEffects(argument)})` : `${hole.id}(${withEffects(argument)})`;
|
|
4228
|
+
blocks.push("", ` describe(${quote(hole.id)}, () => {`);
|
|
4229
|
+
if (hole.kind === "validate") {
|
|
4230
|
+
hole.accepts.forEach((accept, index) => {
|
|
4231
|
+
blocks.push(
|
|
4232
|
+
` it(${quote(`accepte l'exemple ${index + 1} : ${JSON.stringify(accept)}`)}, async () => {`,
|
|
4233
|
+
` expect((${call(JSON.stringify(accept))}).ok).toBe(true);`,
|
|
4234
|
+
" });"
|
|
4235
|
+
);
|
|
4236
|
+
});
|
|
4237
|
+
blocks.push(
|
|
4238
|
+
` it(${quote(`refuse ${JSON.stringify(hole.reject)} avec ${hole.errorCode}${hole.hole.suggestedFix === true ? " et un suggestedFix" : ""}`)}, async () => {`,
|
|
4239
|
+
` const result = ${call(JSON.stringify(hole.reject))};`,
|
|
4240
|
+
" expect(result.ok).toBe(false);",
|
|
4241
|
+
" if (result.ok) return;",
|
|
4242
|
+
` expect(result.code).toBe(${quote(hole.errorCode)});`,
|
|
4243
|
+
" expect(result.suggestedFix.length).toBeGreaterThan(0);",
|
|
4244
|
+
" });"
|
|
4245
|
+
);
|
|
4246
|
+
} else if (hole.kind === "derive") {
|
|
4247
|
+
hole.accepts.forEach((accept, index) => {
|
|
4248
|
+
blocks.push(
|
|
4249
|
+
` it(${quote(`d\xE9rive ${JSON.stringify(accept.output)} de l'exemple ${index + 1} : ${JSON.stringify(accept.input)}`)}, async () => {`,
|
|
4250
|
+
` const result = ${call(JSON.stringify(accept.input))};`,
|
|
4251
|
+
" expect(result.ok).toBe(true);",
|
|
4252
|
+
" if (!result.ok) return;",
|
|
4253
|
+
` expect(result.value).toEqual(${JSON.stringify(accept.output)});`,
|
|
4254
|
+
" });"
|
|
4255
|
+
);
|
|
4256
|
+
});
|
|
4257
|
+
if (hole.reject !== null && hole.errorCode !== null) {
|
|
4258
|
+
blocks.push(
|
|
4259
|
+
` it(${quote(`refuse ${JSON.stringify(hole.reject)} avec ${hole.errorCode}`)}, async () => {`,
|
|
4260
|
+
` const result = ${call(JSON.stringify(hole.reject))};`,
|
|
4261
|
+
" expect(result.ok).toBe(false);",
|
|
4262
|
+
" if (result.ok) return;",
|
|
4263
|
+
` expect(result.code).toBe(${quote(hole.errorCode)});`,
|
|
4264
|
+
" expect(result.suggestedFix.length).toBeGreaterThan(0);",
|
|
4265
|
+
" });"
|
|
4266
|
+
);
|
|
4267
|
+
}
|
|
4268
|
+
} else if (hole.kind === "project") {
|
|
4269
|
+
const carrier = plan.operations.find((operation) => operation.projectHoles.some((candidate) => candidate.id === hole.id));
|
|
4270
|
+
if (carrier === void 0) throw new Error(`generation plan: project hole ${hole.id} has no carrier`);
|
|
4271
|
+
needsOutputs.add(carrier.outputSchema);
|
|
4272
|
+
hole.accepts.forEach((accept, index) => {
|
|
4273
|
+
blocks.push(
|
|
4274
|
+
` it(${quote(`forme la sortie pour le contexte ${JSON.stringify(accept.context)} (exemple ${index + 1})`)}, () => {`,
|
|
4275
|
+
` const input = ${carrier.outputSchema}.parse(${JSON.stringify(projectInputOf(plan, carrier, accept.input))});`,
|
|
4276
|
+
` expect(${hole.id}(${JSON.stringify(accept.context)}, input)).toMatchObject(${JSON.stringify(accept.output)});`,
|
|
4277
|
+
" });"
|
|
4278
|
+
);
|
|
4279
|
+
});
|
|
4280
|
+
} else if (hole.kind === "aggregate") {
|
|
4281
|
+
const constant = `EXAMPLES_OF_${constantNameOf(hole.id)}`;
|
|
4282
|
+
needsExamples.add(constant);
|
|
4283
|
+
hole.accepts.forEach((accept, index) => {
|
|
4284
|
+
blocks.push(
|
|
4285
|
+
` it(${quote(`rend ${JSON.stringify(accept.value)} pour l'exemple ${index + 1} (${accept.rows.length} ligne(s))`)}, async () => {`,
|
|
4286
|
+
` const example = ${constant}[${index}];`,
|
|
4287
|
+
' if (example === undefined) throw new Error("example missing");',
|
|
4288
|
+
` expect(${call(`example.input, example.rows${hole.from.length === 0 ? "" : ", example.shared"}`)}).toEqual(example.value);`,
|
|
4289
|
+
" });"
|
|
4290
|
+
);
|
|
4291
|
+
});
|
|
4292
|
+
} else {
|
|
4293
|
+
blocks.push(
|
|
4294
|
+
` it("accepte le fichier exemple et rend ses lignes", async () => {`,
|
|
4295
|
+
` const result = ${call(`{ text: ${JSON.stringify(hole.hole.examples.accept.input)} }`)};`,
|
|
4296
|
+
" expect(result.ok).toBe(true);",
|
|
4297
|
+
" if (!result.ok) return;",
|
|
4298
|
+
` expect(result.value).toEqual(${JSON.stringify(hole.hole.examples.accept.output)});`,
|
|
4299
|
+
" });",
|
|
4300
|
+
` it(${quote(`refuse le fichier illisible avec ${hole.errorCode} et sa ligne`)}, async () => {`,
|
|
4301
|
+
` const result = ${call(`{ text: ${JSON.stringify(hole.hole.examples.reject.input)} }`)};`,
|
|
4302
|
+
" expect(result.ok).toBe(false);",
|
|
4303
|
+
" if (result.ok) return;",
|
|
4304
|
+
` expect(result.code).toBe(${quote(hole.errorCode)});`,
|
|
4305
|
+
` expect(result.line).toBe(${JSON.stringify(hole.hole.examples.reject.line ?? 1)});`,
|
|
4306
|
+
" expect(result.suggestedFix.length).toBeGreaterThan(0);",
|
|
4307
|
+
" });"
|
|
4308
|
+
);
|
|
4309
|
+
}
|
|
4310
|
+
blocks.push(" });");
|
|
4311
|
+
}
|
|
4312
|
+
return fileOf(
|
|
4313
|
+
tsGeneratedHeader(plan.generatorVersion),
|
|
4314
|
+
intentBlock(
|
|
4315
|
+
wrap(
|
|
4316
|
+
`Pure tests of the holes of ${plan.featureId}, derived from their examples (D23, D32): each accept example passes, the reject example answers the hole's code. Red while a body reads todo; what a candidate body is run against (D24).${plan.holes.length !== testedHoles.length ? " The view holes are proven on the bench, not here." : ""}`
|
|
4317
|
+
)
|
|
4318
|
+
),
|
|
4319
|
+
'import { describe, expect, it } from "vitest";',
|
|
4320
|
+
needsClock ? 'import { fixedClock } from "@ogygie/core/clock";' : null,
|
|
4321
|
+
`import { ${testedHoles.map((hole) => hole.id).sort().join(", ")} } from "./logic.js";`,
|
|
4322
|
+
needsOutputs.size === 0 ? null : `import { ${[...needsOutputs].sort().join(", ")} } from "./schemas.js";`,
|
|
4323
|
+
needsExamples.size === 0 ? null : `import { ${[...needsExamples].sort().join(", ")} } from "./factories.gen.js";`,
|
|
4324
|
+
needsClock ? "" : null,
|
|
4325
|
+
needsClock ? `const CLOCK = fixedClock(${quote(HOLE_TESTS_CLOCK_INSTANT)});` : null,
|
|
4326
|
+
"",
|
|
4327
|
+
`describe(${quote(`${plan.featureId} \u2014 trous`)}, () => {`,
|
|
4328
|
+
...blocks,
|
|
4329
|
+
"});"
|
|
4330
|
+
);
|
|
4331
|
+
}
|
|
4332
|
+
|
|
4014
4333
|
// src/hole-shape.ts
|
|
4015
4334
|
import { createHash } from "node:crypto";
|
|
4016
4335
|
import { readdirSync, readFileSync as readFileSync2 } from "node:fs";
|
|
@@ -4073,7 +4392,7 @@ function normalizedTypeOf(model) {
|
|
|
4073
4392
|
return `${base}${model.nullable ? "?" : ""}`;
|
|
4074
4393
|
}
|
|
4075
4394
|
var HoleShapeSchema = z6.strictObject({
|
|
4076
|
-
kind: z6.enum(["validate", "transform", "derive", "project", "view", "custom-view"]),
|
|
4395
|
+
kind: z6.enum(["validate", "transform", "derive", "project", "view", "aggregate", "custom-view"]),
|
|
4077
4396
|
/** Normalized types of the fields read, by position. */
|
|
4078
4397
|
reads: z6.array(z6.string()),
|
|
4079
4398
|
/** Transform only: normalized types of the columns, by position. */
|
|
@@ -4107,7 +4426,7 @@ function hash16(text) {
|
|
|
4107
4426
|
}
|
|
4108
4427
|
function rowModelOf2(hole, plan) {
|
|
4109
4428
|
const carrier = plan.operations.find(
|
|
4110
|
-
(operation) => operation.validateHoles.some((candidate) => candidate.id === hole.id) || operation.transformHole?.id === hole.id || operation.deriveHoles.some((candidate) => candidate.id === hole.id)
|
|
4429
|
+
(operation) => operation.validateHoles.some((candidate) => candidate.id === hole.id) || operation.transformHole?.id === hole.id || operation.deriveHoles.some((candidate) => candidate.id === hole.id) || operation.aggregateHoles.some((candidate) => candidate.id === hole.id)
|
|
4111
4430
|
);
|
|
4112
4431
|
if (carrier === void 0) return null;
|
|
4113
4432
|
if (carrier.kind === "import") {
|
|
@@ -4143,6 +4462,9 @@ function shapeOfHole(hole, plan) {
|
|
|
4143
4462
|
if (hole.kind === "view") {
|
|
4144
4463
|
return { kind: "view", reads: hole.from.map(() => "table"), columns: hole.columns.map((column) => column.sqlType), codeRole: "none", returns: `view:${hole.shape}` };
|
|
4145
4464
|
}
|
|
4465
|
+
if (hole.kind === "aggregate") {
|
|
4466
|
+
return { kind: "aggregate", reads: readTypes(hole.reads), columns: ["rows", ...hole.from.map(() => "table")], codeRole: "none", returns: normalizedTypeOf(hole.writesModel) };
|
|
4467
|
+
}
|
|
4146
4468
|
return {
|
|
4147
4469
|
kind: "transform",
|
|
4148
4470
|
reads: ["string:text"],
|
|
@@ -4166,7 +4488,7 @@ function signatureHashOf(signature) {
|
|
|
4166
4488
|
}
|
|
4167
4489
|
function bodyTemplateOf(body, hole) {
|
|
4168
4490
|
const replacements = [];
|
|
4169
|
-
if (hole.kind === "validate" || hole.kind === "derive") {
|
|
4491
|
+
if (hole.kind === "validate" || hole.kind === "derive" || hole.kind === "aggregate") {
|
|
4170
4492
|
hole.reads.forEach((name, index) => replacements.push([name, `{{read:${index}}}`]));
|
|
4171
4493
|
} else if (hole.kind === "transform") {
|
|
4172
4494
|
hole.columns.forEach((name, index) => replacements.push([name, `{{column:${index}}}`]));
|
|
@@ -4185,7 +4507,7 @@ function bodyTemplateOf(body, hole) {
|
|
|
4185
4507
|
}
|
|
4186
4508
|
function instantiateBody(template, hole) {
|
|
4187
4509
|
let body = template;
|
|
4188
|
-
if (hole.kind === "validate" || hole.kind === "derive") {
|
|
4510
|
+
if (hole.kind === "validate" || hole.kind === "derive" || hole.kind === "aggregate") {
|
|
4189
4511
|
hole.reads.forEach((name, index) => {
|
|
4190
4512
|
body = body.replaceAll(`{{read:${index}}}`, name);
|
|
4191
4513
|
});
|
|
@@ -4235,143 +4557,6 @@ function libraryFileNameOf(entry) {
|
|
|
4235
4557
|
return `${entry.signatureHash}.json`;
|
|
4236
4558
|
}
|
|
4237
4559
|
|
|
4238
|
-
// src/emit/hole-tests.ts
|
|
4239
|
-
function deepOverlay(base, overlay) {
|
|
4240
|
-
if (Array.isArray(overlay)) {
|
|
4241
|
-
const baseItems = Array.isArray(base) ? base : [];
|
|
4242
|
-
return overlay.map((item, index) => deepOverlay(baseItems[index] ?? baseItems[0], item));
|
|
4243
|
-
}
|
|
4244
|
-
if (overlay !== null && typeof overlay === "object") {
|
|
4245
|
-
const result = base !== null && typeof base === "object" && !Array.isArray(base) ? { ...base } : {};
|
|
4246
|
-
for (const [key, value] of Object.entries(overlay)) result[key] = deepOverlay(result[key], value);
|
|
4247
|
-
return result;
|
|
4248
|
-
}
|
|
4249
|
-
return overlay;
|
|
4250
|
-
}
|
|
4251
|
-
function sampleUnder(model, overlay) {
|
|
4252
|
-
if (model.kind === "object" && overlay !== null && typeof overlay === "object" && !Array.isArray(overlay)) {
|
|
4253
|
-
const given = overlay;
|
|
4254
|
-
const value = {};
|
|
4255
|
-
for (const [name, field] of Object.entries(model.fields)) {
|
|
4256
|
-
if (name in given) value[name] = sampleUnder(field, given[name]);
|
|
4257
|
-
else if (!field.optional) {
|
|
4258
|
-
const sample = sampleOf(field, [name]);
|
|
4259
|
-
value[name] = sample.ok ? sample.value : null;
|
|
4260
|
-
}
|
|
4261
|
-
}
|
|
4262
|
-
return value;
|
|
4263
|
-
}
|
|
4264
|
-
if (model.kind === "array" && Array.isArray(overlay)) return overlay.map((item) => sampleUnder(model.element, item));
|
|
4265
|
-
return overlay;
|
|
4266
|
-
}
|
|
4267
|
-
function projectInputOf(plan, carrier, partial) {
|
|
4268
|
-
void plan;
|
|
4269
|
-
return sampleUnder(carrier.outputModel, partial);
|
|
4270
|
-
}
|
|
4271
|
-
var HOLE_TESTS_CLOCK_INSTANT = "2026-01-01T00:00:00.000Z";
|
|
4272
|
-
function holeDeclaresClock(hole) {
|
|
4273
|
-
return (hole.hole.effects ?? []).includes("clock");
|
|
4274
|
-
}
|
|
4275
|
-
function emitHoleTests(plan) {
|
|
4276
|
-
const blocks = [];
|
|
4277
|
-
const testedHoles = plan.holes.filter((hole) => hole.kind !== "view");
|
|
4278
|
-
const needsOutputs = /* @__PURE__ */ new Set();
|
|
4279
|
-
const needsClock = testedHoles.some((hole) => hole.kind !== "project" && holeDeclaresClock(hole));
|
|
4280
|
-
for (const hole of testedHoles) {
|
|
4281
|
-
const withEffects = (argument) => hole.kind !== "project" && holeDeclaresClock(hole) ? `${argument}, CLOCK` : argument;
|
|
4282
|
-
const call = (argument) => isAsyncHole(hole) ? `await ${hole.id}(${withEffects(argument)})` : `${hole.id}(${withEffects(argument)})`;
|
|
4283
|
-
blocks.push("", ` describe(${quote(hole.id)}, () => {`);
|
|
4284
|
-
if (hole.kind === "validate") {
|
|
4285
|
-
hole.accepts.forEach((accept, index) => {
|
|
4286
|
-
blocks.push(
|
|
4287
|
-
` it(${quote(`accepte l'exemple ${index + 1} : ${JSON.stringify(accept)}`)}, async () => {`,
|
|
4288
|
-
` expect((${call(JSON.stringify(accept))}).ok).toBe(true);`,
|
|
4289
|
-
" });"
|
|
4290
|
-
);
|
|
4291
|
-
});
|
|
4292
|
-
blocks.push(
|
|
4293
|
-
` it(${quote(`refuse ${JSON.stringify(hole.reject)} avec ${hole.errorCode}${hole.hole.suggestedFix === true ? " et un suggestedFix" : ""}`)}, async () => {`,
|
|
4294
|
-
` const result = ${call(JSON.stringify(hole.reject))};`,
|
|
4295
|
-
" expect(result.ok).toBe(false);",
|
|
4296
|
-
" if (result.ok) return;",
|
|
4297
|
-
` expect(result.code).toBe(${quote(hole.errorCode)});`,
|
|
4298
|
-
" expect(result.suggestedFix.length).toBeGreaterThan(0);",
|
|
4299
|
-
" });"
|
|
4300
|
-
);
|
|
4301
|
-
} else if (hole.kind === "derive") {
|
|
4302
|
-
hole.accepts.forEach((accept, index) => {
|
|
4303
|
-
blocks.push(
|
|
4304
|
-
` it(${quote(`d\xE9rive ${JSON.stringify(accept.output)} de l'exemple ${index + 1} : ${JSON.stringify(accept.input)}`)}, async () => {`,
|
|
4305
|
-
` const result = ${call(JSON.stringify(accept.input))};`,
|
|
4306
|
-
" expect(result.ok).toBe(true);",
|
|
4307
|
-
" if (!result.ok) return;",
|
|
4308
|
-
` expect(result.value).toEqual(${JSON.stringify(accept.output)});`,
|
|
4309
|
-
" });"
|
|
4310
|
-
);
|
|
4311
|
-
});
|
|
4312
|
-
if (hole.reject !== null && hole.errorCode !== null) {
|
|
4313
|
-
blocks.push(
|
|
4314
|
-
` it(${quote(`refuse ${JSON.stringify(hole.reject)} avec ${hole.errorCode}`)}, async () => {`,
|
|
4315
|
-
` const result = ${call(JSON.stringify(hole.reject))};`,
|
|
4316
|
-
" expect(result.ok).toBe(false);",
|
|
4317
|
-
" if (result.ok) return;",
|
|
4318
|
-
` expect(result.code).toBe(${quote(hole.errorCode)});`,
|
|
4319
|
-
" expect(result.suggestedFix.length).toBeGreaterThan(0);",
|
|
4320
|
-
" });"
|
|
4321
|
-
);
|
|
4322
|
-
}
|
|
4323
|
-
} else if (hole.kind === "project") {
|
|
4324
|
-
const carrier = plan.operations.find((operation) => operation.projectHoles.some((candidate) => candidate.id === hole.id));
|
|
4325
|
-
if (carrier === void 0) throw new Error(`generation plan: project hole ${hole.id} has no carrier`);
|
|
4326
|
-
needsOutputs.add(carrier.outputSchema);
|
|
4327
|
-
hole.accepts.forEach((accept, index) => {
|
|
4328
|
-
blocks.push(
|
|
4329
|
-
` it(${quote(`forme la sortie pour le contexte ${JSON.stringify(accept.context)} (exemple ${index + 1})`)}, () => {`,
|
|
4330
|
-
` const input = ${carrier.outputSchema}.parse(${JSON.stringify(projectInputOf(plan, carrier, accept.input))});`,
|
|
4331
|
-
` expect(${hole.id}(${JSON.stringify(accept.context)}, input)).toMatchObject(${JSON.stringify(accept.output)});`,
|
|
4332
|
-
" });"
|
|
4333
|
-
);
|
|
4334
|
-
});
|
|
4335
|
-
} else {
|
|
4336
|
-
blocks.push(
|
|
4337
|
-
` it("accepte le fichier exemple et rend ses lignes", async () => {`,
|
|
4338
|
-
` const result = ${call(`{ text: ${JSON.stringify(hole.hole.examples.accept.input)} }`)};`,
|
|
4339
|
-
" expect(result.ok).toBe(true);",
|
|
4340
|
-
" if (!result.ok) return;",
|
|
4341
|
-
` expect(result.value).toEqual(${JSON.stringify(hole.hole.examples.accept.output)});`,
|
|
4342
|
-
" });",
|
|
4343
|
-
` it(${quote(`refuse le fichier illisible avec ${hole.errorCode} et sa ligne`)}, async () => {`,
|
|
4344
|
-
` const result = ${call(`{ text: ${JSON.stringify(hole.hole.examples.reject.input)} }`)};`,
|
|
4345
|
-
" expect(result.ok).toBe(false);",
|
|
4346
|
-
" if (result.ok) return;",
|
|
4347
|
-
` expect(result.code).toBe(${quote(hole.errorCode)});`,
|
|
4348
|
-
` expect(result.line).toBe(${JSON.stringify(hole.hole.examples.reject.line ?? 1)});`,
|
|
4349
|
-
" expect(result.suggestedFix.length).toBeGreaterThan(0);",
|
|
4350
|
-
" });"
|
|
4351
|
-
);
|
|
4352
|
-
}
|
|
4353
|
-
blocks.push(" });");
|
|
4354
|
-
}
|
|
4355
|
-
return fileOf(
|
|
4356
|
-
tsGeneratedHeader(plan.generatorVersion),
|
|
4357
|
-
intentBlock(
|
|
4358
|
-
wrap(
|
|
4359
|
-
`Pure tests of the holes of ${plan.featureId}, derived from their examples (D23, D32): each accept example passes, the reject example answers the hole's code. Red while a body reads todo; what a candidate body is run against (D24).${plan.holes.length !== testedHoles.length ? " The view holes are proven on the bench, not here." : ""}`
|
|
4360
|
-
)
|
|
4361
|
-
),
|
|
4362
|
-
'import { describe, expect, it } from "vitest";',
|
|
4363
|
-
needsClock ? 'import { fixedClock } from "@ogygie/core/clock";' : null,
|
|
4364
|
-
`import { ${testedHoles.map((hole) => hole.id).sort().join(", ")} } from "./logic.js";`,
|
|
4365
|
-
needsOutputs.size === 0 ? null : `import { ${[...needsOutputs].sort().join(", ")} } from "./schemas.js";`,
|
|
4366
|
-
needsClock ? "" : null,
|
|
4367
|
-
needsClock ? `const CLOCK = fixedClock(${quote(HOLE_TESTS_CLOCK_INSTANT)});` : null,
|
|
4368
|
-
"",
|
|
4369
|
-
`describe(${quote(`${plan.featureId} \u2014 trous`)}, () => {`,
|
|
4370
|
-
...blocks,
|
|
4371
|
-
"});"
|
|
4372
|
-
);
|
|
4373
|
-
}
|
|
4374
|
-
|
|
4375
4560
|
// src/emit/mssql/sql-types.ts
|
|
4376
4561
|
function sqlColumnName(plan, column) {
|
|
4377
4562
|
return plan.target.naming === "pascal" ? column.columnName.charAt(0).toUpperCase() + column.columnName.slice(1) : column.columnName;
|
|
@@ -4647,6 +4832,33 @@ function mssqlStatements(plan) {
|
|
|
4647
4832
|
parameters: []
|
|
4648
4833
|
});
|
|
4649
4834
|
}
|
|
4835
|
+
for (const table of plan.sharedTables) {
|
|
4836
|
+
statements.push({
|
|
4837
|
+
key: `select${table.typeName}Rows`,
|
|
4838
|
+
kind: "selectShared",
|
|
4839
|
+
text: `SELECT ${selectList(plan, "t", table.columns)} FROM ${qualifiedTable(plan, table.sqlName)} AS t`,
|
|
4840
|
+
table: table.sqlName,
|
|
4841
|
+
rowType: table.rowType,
|
|
4842
|
+
parameters: []
|
|
4843
|
+
});
|
|
4844
|
+
}
|
|
4845
|
+
const arrangingRows = plan.operations.every((operation) => operation.kind !== "create") && plan.operations.some((operation) => operation.aggregateHoles.length > 0);
|
|
4846
|
+
if (arrangingRows) {
|
|
4847
|
+
const arrangeOf = (sqlName, columns, rowType, typeName) => {
|
|
4848
|
+
const written = [...columns.filter((column) => column.key === "id"), ...columns.filter((column) => column.key !== "id")];
|
|
4849
|
+
return {
|
|
4850
|
+
key: `arrange${typeName}Row`,
|
|
4851
|
+
kind: "insert",
|
|
4852
|
+
text: `INSERT INTO ${qualifiedTable(plan, sqlName)} (${written.map((column) => sqlColumnName(plan, column)).join(", ")}) OUTPUT ${outputList(plan, "INSERTED", columns)} VALUES (${written.map((column) => `@${column.key}`).join(", ")})`,
|
|
4853
|
+
table: sqlName,
|
|
4854
|
+
rowType,
|
|
4855
|
+
parameters: written.map((column) => column.key),
|
|
4856
|
+
written: written.map((column) => column.key)
|
|
4857
|
+
};
|
|
4858
|
+
};
|
|
4859
|
+
statements.push(arrangeOf(resource.sqlName, resource.columns, resource.rowType, plan.resource.typeName));
|
|
4860
|
+
for (const table of plan.sharedTables) statements.push(arrangeOf(table.sqlName, table.columns, table.rowType, table.typeName));
|
|
4861
|
+
}
|
|
4650
4862
|
if (keyField !== null && keyColumn !== null) {
|
|
4651
4863
|
const keyName = sqlColumnName(plan, keyColumn);
|
|
4652
4864
|
statements.push({
|
|
@@ -4812,6 +5024,10 @@ function rowInterfaceLines(name, columns) {
|
|
|
4812
5024
|
function parametersLiteral(entries) {
|
|
4813
5025
|
return `{ ${entries.map(([name, expression]) => `${name}: ${expression}`).join(", ")} }`;
|
|
4814
5026
|
}
|
|
5027
|
+
function parameterValue(column, expression) {
|
|
5028
|
+
if (column.sqlType === "timestamptz") return expression;
|
|
5029
|
+
return expression;
|
|
5030
|
+
}
|
|
4815
5031
|
function notFoundLines(plan, codeExpression, indent) {
|
|
4816
5032
|
const key = plan.key?.field ?? "id";
|
|
4817
5033
|
return [
|
|
@@ -4851,6 +5067,34 @@ function viewReadLines(plan, view, statement) {
|
|
|
4851
5067
|
"}"
|
|
4852
5068
|
];
|
|
4853
5069
|
}
|
|
5070
|
+
function sharedReadLines(plan, table, statement) {
|
|
5071
|
+
return [
|
|
5072
|
+
`/** The rows of the shared table ${table.sqlName} (D60) \u2014 read-only, the product owns it. */`,
|
|
5073
|
+
`export async function ${statement.key}(db: ${plan.databasePort?.typeName ?? "Database"}): Promise<${table.rowType}[]> {`,
|
|
5074
|
+
` return db.query<${table.rowType}>(SQL.${statement.key});`,
|
|
5075
|
+
"}"
|
|
5076
|
+
];
|
|
5077
|
+
}
|
|
5078
|
+
function arrangeLines(plan, statement, typeName, columns, withId) {
|
|
5079
|
+
const written = statement.written ?? [];
|
|
5080
|
+
const valueOf = (field) => {
|
|
5081
|
+
if (field === "id") return "crypto.randomUUID()";
|
|
5082
|
+
const column = columns.find((candidate) => candidate.key === field);
|
|
5083
|
+
return column === void 0 ? `row.${field}` : parameterValue(column, `row.${field}`);
|
|
5084
|
+
};
|
|
5085
|
+
return [
|
|
5086
|
+
`/** TESTS ONLY (D60): rows of ${statement.table} arranged at their schema \u2014 the feature never writes them, they come from elsewhere. */`,
|
|
5087
|
+
`export async function arrange${typeName}Rows(db: ${plan.databasePort?.typeName ?? "Database"}, rows: readonly ${withId ? `Omit<${typeName}, "id">` : typeName}[]): Promise<${statement.rowType}[]> {`,
|
|
5088
|
+
` const inserted: ${statement.rowType}[] = [];`,
|
|
5089
|
+
" for (const row of rows) {",
|
|
5090
|
+
` const [written] = await db.query<${statement.rowType}>(SQL.${statement.key}, ${parametersLiteral(written.map((field) => [field, valueOf(field)]))});`,
|
|
5091
|
+
" if (written === undefined) throwOgygieError(insertReturnedNothing());",
|
|
5092
|
+
" inserted.push(written);",
|
|
5093
|
+
" }",
|
|
5094
|
+
" return inserted;",
|
|
5095
|
+
"}"
|
|
5096
|
+
];
|
|
5097
|
+
}
|
|
4854
5098
|
function referenceLines(plan, table, column, countStatement, insertStatement) {
|
|
4855
5099
|
const database = plan.databasePort?.typeName ?? "Database";
|
|
4856
5100
|
const typeName = table.rowType.replace(/Row$/u, "");
|
|
@@ -5061,6 +5305,7 @@ function emitMssqlRepository(plan) {
|
|
|
5061
5305
|
for (const slot of plan.slots) rows.push("", ...rowInterfaceLines(slot.table.rowType, slot.table.columns));
|
|
5062
5306
|
for (const reference of plan.references) if (reference.table !== null) rows.push("", ...rowInterfaceLines(reference.table.rowType, reference.table.columns));
|
|
5063
5307
|
for (const view of views) rows.push("", ...rowInterfaceLines(view.rowType, view.columns));
|
|
5308
|
+
for (const table of plan.sharedTables) rows.push("", ...rowInterfaceLines(table.rowType, table.columns));
|
|
5064
5309
|
const sqlLines = ["/** Every statement of this feature, `@named` parameters, read back under camelCase aliases \u2014 the stub answers these very texts (D33). */", "export const SQL = {", ...statements.map((statement) => ` ${statement.key}: ${quote(statement.text)},`), "} as const;"];
|
|
5065
5310
|
const sections = [];
|
|
5066
5311
|
for (const operation of plan.operations.filter((candidate) => candidate.kind === "read")) sections.push("", ...readFunctionLines(plan, operation, statementNamed(statements, `select${operation.pascalId}`)));
|
|
@@ -5071,8 +5316,14 @@ function emitMssqlRepository(plan) {
|
|
|
5071
5316
|
sections.push("", ...referenceLines(plan, reference.table, reference.column, statementNamed(statements, `count${typeName}`), statementNamed(statements, `insert${typeName}`)));
|
|
5072
5317
|
}
|
|
5073
5318
|
for (const view of views) sections.push("", ...viewReadLines(plan, view, statementNamed(statements, `select${view.rowType.replace(/Row$/u, "")}`)));
|
|
5074
|
-
|
|
5319
|
+
for (const table of plan.sharedTables) sections.push("", ...sharedReadLines(plan, table, statementNamed(statements, `select${table.typeName}Rows`)));
|
|
5075
5320
|
const create = plan.operations.find((operation) => operation.kind === "create");
|
|
5321
|
+
const arranging = create === void 0 && plan.operations.some((operation) => operation.aggregateHoles.length > 0);
|
|
5322
|
+
if (arranging) {
|
|
5323
|
+
sections.push("", ...arrangeLines(plan, statementNamed(statements, `arrange${plan.resource.typeName}Row`), plan.resource.typeName, plan.table.columns, true));
|
|
5324
|
+
for (const table of plan.sharedTables) sections.push("", ...arrangeLines(plan, statementNamed(statements, `arrange${table.typeName}Row`), table.typeName, table.columns, false));
|
|
5325
|
+
}
|
|
5326
|
+
if (plan.key !== null) sections.push("", ...loadByKeyLines(plan, statementNamed(statements, `load${plan.resource.typeName}ByKey`)));
|
|
5076
5327
|
const update = plan.operations.find((operation) => operation.kind === "update");
|
|
5077
5328
|
const setOrClear = plan.operations.find((operation) => operation.kind === "setOrClear");
|
|
5078
5329
|
const remove = plan.operations.find((operation) => operation.kind === "delete");
|
|
@@ -5081,7 +5332,7 @@ function emitMssqlRepository(plan) {
|
|
|
5081
5332
|
if (setOrClear !== void 0) sections.push("", ...setOrClearFunctionLines(plan, setOrClear, statements));
|
|
5082
5333
|
if (remove !== void 0) sections.push("", ...deleteFunctionLines(plan, remove, statements));
|
|
5083
5334
|
const inputTypes = [...new Set([create?.inputType, update?.inputType].filter((name) => name !== void 0))].sort();
|
|
5084
|
-
const resourceTypes = [...new Set([plan.key === null ? null : plan.resource.typeName, ...setOrClear?.slot === null || setOrClear?.slot === void 0 ? [] : [setOrClear.slot.typeName]].filter((name) => name !== null))].sort();
|
|
5335
|
+
const resourceTypes = [...new Set([plan.key === null ? null : plan.resource.typeName, ...arranging ? [plan.resource.typeName, ...plan.sharedTables.map((table) => table.typeName)] : [], ...setOrClear?.slot === null || setOrClear?.slot === void 0 ? [] : [setOrClear.slot.typeName]].filter((name) => name !== null))].sort();
|
|
5085
5336
|
return fileOf(
|
|
5086
5337
|
tsGeneratedHeader(plan.generatorVersion),
|
|
5087
5338
|
intentBlock(
|
|
@@ -5162,6 +5413,8 @@ function emitMssqlStub(plan) {
|
|
|
5162
5413
|
const tables = tablesOf(plan);
|
|
5163
5414
|
const views = plan.holes.filter((hole) => hole.kind === "view");
|
|
5164
5415
|
const tableOf = (sqlName) => {
|
|
5416
|
+
const shared = plan.sharedTables.find((candidate) => candidate.sqlName === sqlName);
|
|
5417
|
+
if (shared !== void 0) return { sqlName: shared.sqlName, tsName: shared.tsName, rowType: shared.rowType, newType: `New${shared.typeName}`, mode: "adopted", hasId: false, primaryKey: [], alter: [], columns: shared.columns, indexes: [], uniques: [], foreignKeys: [] };
|
|
5165
5418
|
const table = tables.find((candidate) => candidate.sqlName === sqlName);
|
|
5166
5419
|
if (table === void 0) throw new Error(`generation plan: stub has no table ${sqlName}`);
|
|
5167
5420
|
return table;
|
|
@@ -5181,7 +5434,8 @@ function emitMssqlStub(plan) {
|
|
|
5181
5434
|
cases.push(` const rows = tables.${statement.table};`, ...compareLines(statement));
|
|
5182
5435
|
break;
|
|
5183
5436
|
}
|
|
5184
|
-
case "selectView":
|
|
5437
|
+
case "selectView":
|
|
5438
|
+
case "selectShared": {
|
|
5185
5439
|
rowTypes.add(statement.rowType);
|
|
5186
5440
|
cases.push(` return [...tables.${statement.table}];`);
|
|
5187
5441
|
break;
|
|
@@ -5316,9 +5570,9 @@ function emitMssqlStub(plan) {
|
|
|
5316
5570
|
}
|
|
5317
5571
|
cases.push(" }");
|
|
5318
5572
|
}
|
|
5319
|
-
const tableFields = [...tables.map((table) => ` ${table.sqlName}: ${table.rowType}[];`), ...views.map((view) => ` ${view.name}: ${view.rowType}[];`)];
|
|
5320
|
-
const emptyFields = [...tables.map((table) => `${table.sqlName}: []`), ...views.map((view) => `${view.name}: []`)];
|
|
5321
|
-
const importedRowTypes = [.../* @__PURE__ */ new Set([...tables.map((table) => table.rowType), ...views.map((view) => view.rowType)])].sort();
|
|
5573
|
+
const tableFields = [...tables.map((table) => ` ${table.sqlName}: ${table.rowType}[];`), ...views.map((view) => ` ${view.name}: ${view.rowType}[];`), ...plan.sharedTables.map((table) => ` ${table.sqlName}: ${table.rowType}[];`)];
|
|
5574
|
+
const emptyFields = [...tables.map((table) => `${table.sqlName}: []`), ...views.map((view) => `${view.name}: []`), ...plan.sharedTables.map((table) => `${table.sqlName}: []`)];
|
|
5575
|
+
const importedRowTypes = [.../* @__PURE__ */ new Set([...tables.map((table) => table.rowType), ...views.map((view) => view.rowType), ...plan.sharedTables.map((table) => table.rowType)])].sort();
|
|
5322
5576
|
return fileOf(
|
|
5323
5577
|
tsGeneratedHeader(plan.generatorVersion),
|
|
5324
5578
|
intentBlock(
|
|
@@ -5428,8 +5682,9 @@ function sampleOrThrow(model, variant, label) {
|
|
|
5428
5682
|
}
|
|
5429
5683
|
return sample.value;
|
|
5430
5684
|
}
|
|
5431
|
-
function acceptOverlay(holes, derives, variant) {
|
|
5685
|
+
function acceptOverlay(holes, derives, variant, aggregates = []) {
|
|
5432
5686
|
const overlay = {};
|
|
5687
|
+
for (const hole of aggregates) Object.assign(overlay, hole.accepts[Math.min(variant, hole.accepts.length - 1)]?.input ?? {});
|
|
5433
5688
|
for (const hole of holes) {
|
|
5434
5689
|
const example = hole.accepts[Math.min(variant, hole.accepts.length - 1)] ?? {};
|
|
5435
5690
|
Object.assign(overlay, example);
|
|
@@ -5443,7 +5698,8 @@ function acceptOverlay(holes, derives, variant) {
|
|
|
5443
5698
|
function validInputsOf(plan, operation) {
|
|
5444
5699
|
const holes = operation.kind === "import" ? [] : operation.validateHoles;
|
|
5445
5700
|
const derives = operation.deriveHoles;
|
|
5446
|
-
const
|
|
5701
|
+
const aggregates = operation.aggregateHoles;
|
|
5702
|
+
const variants = Math.max(1, ...holes.map((hole) => hole.accepts.length), ...derives.map((hole) => hole.accepts.length), ...aggregates.map((hole) => hole.accepts.length));
|
|
5447
5703
|
if (operation.kind === "update" && operation.inputModel.kind === "object") {
|
|
5448
5704
|
return Array.from({ length: variants }, (_, variant) => {
|
|
5449
5705
|
const body = {};
|
|
@@ -5455,7 +5711,7 @@ function validInputsOf(plan, operation) {
|
|
|
5455
5711
|
});
|
|
5456
5712
|
}
|
|
5457
5713
|
const base = sampleOrThrow(operation.inputModel, 0, operation.inputSchema);
|
|
5458
|
-
return Array.from({ length: variants }, (_, variant) => ({ ...base, ...acceptOverlay(holes, derives, variant) }));
|
|
5714
|
+
return Array.from({ length: variants }, (_, variant) => ({ ...base, ...acceptOverlay(holes, derives, variant, aggregates) }));
|
|
5459
5715
|
}
|
|
5460
5716
|
function derivedOf(operation, variant = 0) {
|
|
5461
5717
|
const derived = {};
|
|
@@ -5490,7 +5746,7 @@ function sampleOfSqlType(sqlType) {
|
|
|
5490
5746
|
if (sqlType === "uuid") return "00000000-0000-4000-8000-000000000001";
|
|
5491
5747
|
return "arranged";
|
|
5492
5748
|
}
|
|
5493
|
-
function
|
|
5749
|
+
function constantNameOf2(typeName) {
|
|
5494
5750
|
return typeName.replace(/([a-z0-9])([A-Z])/gu, "$1_$2").toUpperCase();
|
|
5495
5751
|
}
|
|
5496
5752
|
function sampleValueOf(model, variant = 0) {
|
|
@@ -5511,13 +5767,13 @@ function emitFactories(plan) {
|
|
|
5511
5767
|
sections.push(
|
|
5512
5768
|
"",
|
|
5513
5769
|
`/** Structurally invalid ${operation.inputType}: the required field \`${invalidImport.removedField}\` is missing. */`,
|
|
5514
|
-
`export const INVALID_${
|
|
5770
|
+
`export const INVALID_${constantNameOf2(operation.inputType)}: Record<string, unknown> = ${JSON.stringify(invalidImport.value)};`
|
|
5515
5771
|
);
|
|
5516
5772
|
}
|
|
5517
5773
|
continue;
|
|
5518
5774
|
}
|
|
5519
5775
|
typeImports.add(operation.inputType);
|
|
5520
|
-
const constant = `VALID_${
|
|
5776
|
+
const constant = `VALID_${constantNameOf2(operation.inputType)}S`;
|
|
5521
5777
|
const factory = `valid${operation.inputType}`;
|
|
5522
5778
|
sections.push(
|
|
5523
5779
|
"",
|
|
@@ -5537,13 +5793,13 @@ function emitFactories(plan) {
|
|
|
5537
5793
|
sections.push(
|
|
5538
5794
|
"",
|
|
5539
5795
|
`/** Structurally invalid ${operation.inputType}: the required field \`${invalid.removedField}\` is missing. */`,
|
|
5540
|
-
`export const INVALID_${
|
|
5796
|
+
`export const INVALID_${constantNameOf2(operation.inputType)}: Record<string, unknown> = ${JSON.stringify(invalid.value)};`
|
|
5541
5797
|
);
|
|
5542
5798
|
} else if (operation.kind === "update") {
|
|
5543
5799
|
sections.push(
|
|
5544
5800
|
"",
|
|
5545
5801
|
`/** Structurally invalid ${operation.inputType}: a partial body with no field at all \u2014 the contract refuses it. */`,
|
|
5546
|
-
`export const INVALID_${
|
|
5802
|
+
`export const INVALID_${constantNameOf2(operation.inputType)}: Record<string, unknown> = {};`
|
|
5547
5803
|
);
|
|
5548
5804
|
}
|
|
5549
5805
|
}
|
|
@@ -5575,6 +5831,31 @@ function emitFactories(plan) {
|
|
|
5575
5831
|
} else if (create !== null) {
|
|
5576
5832
|
sections.push("", "/** No derive hole: an arranged row carries nothing beyond its input. */", "export const DERIVED_OF_VALID_CREATES: readonly Record<string, unknown>[] = [{}];");
|
|
5577
5833
|
}
|
|
5834
|
+
for (const hole of plan.holes) {
|
|
5835
|
+
if (hole.kind !== "aggregate") continue;
|
|
5836
|
+
const carrier = plan.operations.find((operation) => operation.aggregateHoles.some((candidate) => candidate.id === hole.id));
|
|
5837
|
+
if (carrier === void 0) continue;
|
|
5838
|
+
typeImports.add(hole.resourceTypeName);
|
|
5839
|
+
typeImports.add(hole.outputTypeName);
|
|
5840
|
+
if (hole.reads.length > 0) typeImports.add(hole.inputTypeName);
|
|
5841
|
+
for (const table of hole.from) typeImports.add(table.typeName);
|
|
5842
|
+
const inputType = hole.reads.length === 0 ? "Record<never, never>" : `Pick<${hole.inputTypeName}, ${hole.reads.map(quote).join(" | ")}>`;
|
|
5843
|
+
const sharedType = hole.from.length === 0 ? "Record<never, never>" : `{ ${hole.from.map((table) => `${table.tsName}: readonly ${table.typeName}[]`).join("; ")} }`;
|
|
5844
|
+
const examples = hole.accepts.map((accept) => {
|
|
5845
|
+
const fullInput = sampleUnder(carrier.inputModel, accept.input);
|
|
5846
|
+
const input = Object.fromEntries(hole.reads.map((field) => [field, fullInput[field]]));
|
|
5847
|
+
const rows = accept.rows.map((row) => sampleUnder(plan.resource.model, row));
|
|
5848
|
+
const shared = Object.fromEntries(hole.from.map((table) => [table.tsName, (accept.shared[table.sqlName] ?? []).map((row) => sampleUnder(table.model, row))]));
|
|
5849
|
+
return { input, rows, shared, value: accept.value };
|
|
5850
|
+
});
|
|
5851
|
+
sections.push(
|
|
5852
|
+
"",
|
|
5853
|
+
`/** The complete examples of the aggregate hole ${hole.id} (D60): input pick, rows and shared rows completed under their schemas, the value expected \u2014 one fixture for the pure test and the behaviour test. */`,
|
|
5854
|
+
`export const EXAMPLES_OF_${constantNameOf(hole.id)}: readonly { input: ${inputType}; rows: readonly ${hole.resourceTypeName}[]; shared: ${sharedType}; value: ${hole.outputTypeName}[${quote(hole.writes)}] }[] = [`,
|
|
5855
|
+
...examples.map((example) => ` ${JSON.stringify(example)},`),
|
|
5856
|
+
"];"
|
|
5857
|
+
);
|
|
5858
|
+
}
|
|
5578
5859
|
for (const reference of plan.references) {
|
|
5579
5860
|
if (reference.table === null) continue;
|
|
5580
5861
|
const model = reference.schemaName === null ? void 0 : plan.schemaModels[reference.schemaName];
|
|
@@ -5640,14 +5921,14 @@ function emitFactories(plan) {
|
|
|
5640
5921
|
sections.push(
|
|
5641
5922
|
"",
|
|
5642
5923
|
`/** The input ${hole.id} rejects (its reject example), as an overlay of a valid input. */`,
|
|
5643
|
-
`export const REJECT_EXAMPLE_OF_${
|
|
5924
|
+
`export const REJECT_EXAMPLE_OF_${constantNameOf2(hole.id)} = ${JSON.stringify(hole.reject)} as const;`
|
|
5644
5925
|
);
|
|
5645
5926
|
}
|
|
5646
5927
|
if (hole.kind === "derive" && hole.reject !== null) {
|
|
5647
5928
|
sections.push(
|
|
5648
5929
|
"",
|
|
5649
5930
|
`/** The input ${hole.id} refuses to derive from (its reject example), as an overlay of a valid input. */`,
|
|
5650
|
-
`export const REJECT_EXAMPLE_OF_${
|
|
5931
|
+
`export const REJECT_EXAMPLE_OF_${constantNameOf2(hole.id)} = ${JSON.stringify(hole.reject)} as const;`
|
|
5651
5932
|
);
|
|
5652
5933
|
}
|
|
5653
5934
|
}
|
|
@@ -5670,7 +5951,7 @@ function markdownGeneratedHeader(version) {
|
|
|
5670
5951
|
}
|
|
5671
5952
|
function carriersOf(hole, plan) {
|
|
5672
5953
|
return plan.operations.filter(
|
|
5673
|
-
(operation) => operation.validateHoles.some((candidate) => candidate.id === hole.id) || operation.transformHole?.id === hole.id || operation.deriveHoles.some((candidate) => candidate.id === hole.id) || operation.projectHoles.some((candidate) => candidate.id === hole.id) || operation.viewHoles.some((candidate) => candidate.id === hole.id)
|
|
5954
|
+
(operation) => operation.validateHoles.some((candidate) => candidate.id === hole.id) || operation.transformHole?.id === hole.id || operation.deriveHoles.some((candidate) => candidate.id === hole.id) || operation.projectHoles.some((candidate) => candidate.id === hole.id) || operation.viewHoles.some((candidate) => candidate.id === hole.id) || operation.aggregateHoles.some((candidate) => candidate.id === hole.id)
|
|
5674
5955
|
).map((operation) => `\`${operation.id}\` (${operation.method} ${operation.path})`);
|
|
5675
5956
|
}
|
|
5676
5957
|
function tick(name) {
|
|
@@ -5712,6 +5993,17 @@ function contractLines(hole, plan, shape) {
|
|
|
5712
5993
|
"## Champs",
|
|
5713
5994
|
...hole.writes.map((path) => `- ${tick(path)} : \xE9crit par le trou`)
|
|
5714
5995
|
];
|
|
5996
|
+
case "aggregate":
|
|
5997
|
+
return [
|
|
5998
|
+
`- lit : ${hole.reads.length === 0 ? "rien de l'entr\xE9e" : `${hole.reads.map(tick).join(", ")} de ${tick(hole.inputTypeName)}`}, les lignes de la ressource (${tick(`readonly ${hole.resourceTypeName}[]`)} : TOUTES celles du tenant, au sch\xE9ma, dans l'ordre de la lecture)${hole.from.length === 0 ? "" : ` et les lignes des tables partag\xE9es ${hole.from.map((table) => `${tick(table.sqlName)} (${tick(`shared.${table.tsName}: readonly ${table.typeName}[]`)})`).join(", ")}`}`,
|
|
5999
|
+
`- rend : ${tick(hole.writes)} de la sortie \u2014 ${tick(`${hole.outputTypeName}["${hole.writes}"]`)}, la VALEUR, jamais un ${tick("HoleResult")} : une agr\xE9gation ne refuse pas (D60)`,
|
|
6000
|
+
`- effets : ${effects(hole.hole.effects)}`,
|
|
6001
|
+
"- filtre : c'est le trou qui garde les lignes que l'entr\xE9e demande (une p\xE9riode, un site) ; le d\xE9p\xF4t ne filtre pas",
|
|
6002
|
+
"",
|
|
6003
|
+
"## Champs",
|
|
6004
|
+
...hole.reads.map((name, index) => `- ${tick(name)} : ${shape.reads[index] ?? "unknown"}`),
|
|
6005
|
+
`- ${tick(hole.writes)} (\xE9crit) : ${shape.returns}`
|
|
6006
|
+
];
|
|
5715
6007
|
case "view":
|
|
5716
6008
|
return [
|
|
5717
6009
|
`- corps : du SQL, dans la migration ${tick(plan.migrationPath)} entre ${tick(`-- @hole ${hole.id}`)} et ${tick(`-- @end-hole ${hole.id}`)} \u2014 ${tick(`create or replace view "${hole.name}" as \u2026`)}`,
|
|
@@ -5748,6 +6040,10 @@ function exampleLines(hole, plan) {
|
|
|
5748
6040
|
];
|
|
5749
6041
|
case "project":
|
|
5750
6042
|
return hole.accepts.map((accept) => `- contexte ${tick(JSON.stringify(accept.context))}, entr\xE9e ${tick(JSON.stringify(accept.input))} \u2192 ${tick(JSON.stringify(accept.output))}`);
|
|
6043
|
+
case "aggregate":
|
|
6044
|
+
return hole.accepts.map(
|
|
6045
|
+
(accept, index) => `- exemple ${index + 1} : entr\xE9e ${tick(JSON.stringify(accept.input))}, lignes ${tick(JSON.stringify(accept.rows))}${Object.keys(accept.shared).length === 0 ? "" : `, partag\xE9es ${tick(JSON.stringify(accept.shared))}`} \u2192 ${tick(hole.writes)} = ${tick(JSON.stringify(accept.value))} (les lignes sont compl\xE9t\xE9es sous leur sch\xE9ma par le test)`
|
|
6046
|
+
);
|
|
5751
6047
|
case "view":
|
|
5752
6048
|
return [
|
|
5753
6049
|
`- lignes de case d'UNE ligne de ressource : ${tick(JSON.stringify(hole.hole.examples.rows))}`,
|
|
@@ -5786,7 +6082,7 @@ function emitHoleDoc(plan, hole, library) {
|
|
|
5786
6082
|
"## Contrat",
|
|
5787
6083
|
`- appel\xE9 par : ${carriersOf(hole, plan).join(", ")}`,
|
|
5788
6084
|
...contractLines(hole, plan, shape),
|
|
5789
|
-
`- forme normalis\xE9e : ${shape.kind} [${(hole.kind === "transform" || hole.kind === "project" || hole.kind === "view" ? shape.columns : shape.reads).join(", ")}] \u2192 ${shape.returns}, code ${shape.codeRole} \u2014 shapeHash \`${shapeHash}\``,
|
|
6085
|
+
`- forme normalis\xE9e : ${shape.kind} [${(hole.kind === "transform" || hole.kind === "project" || hole.kind === "view" || hole.kind === "aggregate" ? shape.columns : shape.reads).join(", ")}] \u2192 ${shape.returns}, code ${shape.codeRole} \u2014 shapeHash \`${shapeHash}\``,
|
|
5790
6086
|
"",
|
|
5791
6087
|
hole.kind === "view" ? "## Colonnes (g\xE9n\xE9r\xE9es, le SQL doit les produire)" : "## Signature (g\xE9n\xE9r\xE9e)",
|
|
5792
6088
|
hole.kind === "view" ? "```sql" : "```ts",
|
|
@@ -5810,8 +6106,8 @@ function emitHoleDoc(plan, hole, library) {
|
|
|
5810
6106
|
"- aucune table hors de celles list\xE9es en `lit` ; aucune \xE9criture ; `null` quand rien n'est pos\xE9 (un crit\xE8re sans cible ne compte pas pour z\xE9ro)",
|
|
5811
6107
|
"- ne touche rien hors des marqueurs : le reste de la migration est r\xE9g\xE9n\xE9r\xE9"
|
|
5812
6108
|
] : [
|
|
5813
|
-
hole.kind === "project" ? "- pur : lit le contexte re\xE7u et la sortie re\xE7ue, rend une sortie du m\xEAme type ; jamais la base ni l'horloge" : "- pur hors de ses effets d\xE9clar\xE9s : ne lit ni horloge ni base ni r\xE9seau au-del\xE0 des param\xE8tres re\xE7us (AGENTS.md principe 5)",
|
|
5814
|
-
hole.kind === "project" ? "- rend la sortie, jamais une exception" : "- r\xE9pond par `accept(...)` ou `refuse(code, cause, suggestedFix[, line])` du runtime ; jamais par une exception",
|
|
6109
|
+
hole.kind === "project" ? "- pur : lit le contexte re\xE7u et la sortie re\xE7ue, rend une sortie du m\xEAme type ; jamais la base ni l'horloge" : hole.kind === "aggregate" ? "- pur hors de l'horloge quand elle est d\xE9clar\xE9e : lit l'entr\xE9e re\xE7ue, les lignes re\xE7ues et les lignes partag\xE9es re\xE7ues, rien d'autre (AGENTS.md principe 5) ; les dates sont des cha\xEEnes ISO, \xE0 comparer comme telles" : "- pur hors de ses effets d\xE9clar\xE9s : ne lit ni horloge ni base ni r\xE9seau au-del\xE0 des param\xE8tres re\xE7us (AGENTS.md principe 5)",
|
|
6110
|
+
hole.kind === "project" ? "- rend la sortie, jamais une exception" : hole.kind === "aggregate" ? "- rend la valeur, jamais une exception ; une agr\xE9gation sur z\xE9ro ligne rend la valeur neutre que la spec dit (0, [], null)" : "- r\xE9pond par `accept(...)` ou `refuse(code, cause, suggestedFix[, line])` du runtime ; jamais par une exception",
|
|
5815
6111
|
"- aucun `any`, aucune d\xE9pendance nouvelle, identifiants et commentaires en anglais",
|
|
5816
6112
|
"- ne touche rien hors du corps : signature, marqueurs et imports sont r\xE9g\xE9n\xE9r\xE9s"
|
|
5817
6113
|
]
|
|
@@ -5821,7 +6117,7 @@ function emitHoleDoc(plan, hole, library) {
|
|
|
5821
6117
|
|
|
5822
6118
|
// src/emit/tests.ts
|
|
5823
6119
|
var UUID_PATTERN = "/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u";
|
|
5824
|
-
function
|
|
6120
|
+
function constantNameOf3(typeName) {
|
|
5825
6121
|
return typeName.replace(/([a-z0-9])([A-Z])/gu, "$1_$2").toUpperCase();
|
|
5826
6122
|
}
|
|
5827
6123
|
function invariant2(condition, message) {
|
|
@@ -5868,7 +6164,7 @@ function harnessLines(plan, options) {
|
|
|
5868
6164
|
const create = createOperation(plan);
|
|
5869
6165
|
const imports = [
|
|
5870
6166
|
`import { ${plan.tenant === null ? "afterEach, beforeEach" : "afterAll, beforeAll"}, describe, expect, it } from "vitest";`,
|
|
5871
|
-
...options.arranging ? ['import { fixedClock } from "@ogygie/core/clock";'] : [],
|
|
6167
|
+
...options.arranging && create !== null ? ['import { fixedClock } from "@ogygie/core/clock";'] : [],
|
|
5872
6168
|
`import type { ${plan.context.typeName} } from ${quote(plan.context.importPath)};`,
|
|
5873
6169
|
`import { ${plan.testBench.exportName} } from ${quote(plan.testBench.importPath)};`,
|
|
5874
6170
|
`import { ${[...fixtureNames].sort().join(", ")} } from ${quote(fixturesImportPath ?? "")};`
|
|
@@ -5936,6 +6232,33 @@ function harnessLines(plan, options) {
|
|
|
5936
6232
|
" return String(a) < String(b) ? -1 : 1;",
|
|
5937
6233
|
"}"
|
|
5938
6234
|
];
|
|
6235
|
+
const arrangingReport = options.arranging && options.report === true && create === null && plan.operations.some((operation) => operation.aggregateHoles.length > 0);
|
|
6236
|
+
if (arrangingReport) {
|
|
6237
|
+
const handleType = plan.tenant === null ? `db: ${plan.databasePort?.typeName ?? "Database"}` : `context: ${plan.context.typeName}`;
|
|
6238
|
+
const handleName = plan.tenant === null ? "db" : "context";
|
|
6239
|
+
imports.push(
|
|
6240
|
+
`import type { ${plan.table.rowType} } from ${plan.target.database === "mssql-raw" ? '"./repository.gen.js"' : '"./db.schema.gen.js"'};`,
|
|
6241
|
+
`import { ${["arrange" + plan.resource.typeName + "Rows", ...plan.sharedTables.map((table) => `arrange${table.typeName}Rows`)].sort().join(", ")} } from "./repository.gen.js";`,
|
|
6242
|
+
`import type { ${[plan.resource.typeName, ...plan.sharedTables.map((table) => table.typeName)].sort().join(", ")} } from "./schemas.js";`
|
|
6243
|
+
);
|
|
6244
|
+
if (plan.tenant === null && plan.databasePort !== null) imports.push(`import type { ${plan.databasePort.typeName} } from ${quote(plan.databasePort.importPath)};`);
|
|
6245
|
+
helpers.push(
|
|
6246
|
+
"",
|
|
6247
|
+
"/** Rows arranged at their schema (D60): the block has no create, the tests put their rows on the bench through the repository's arrangement. */",
|
|
6248
|
+
`async function arrangeRows(${handleType}, rows: readonly Omit<${plan.resource.typeName}, "id">[]): Promise<${plan.table.rowType}[]> {`,
|
|
6249
|
+
` return arrange${plan.resource.typeName}Rows(${handleName}, rows);`,
|
|
6250
|
+
"}"
|
|
6251
|
+
);
|
|
6252
|
+
if (plan.sharedTables.length > 0) {
|
|
6253
|
+
helpers.push(
|
|
6254
|
+
"",
|
|
6255
|
+
"/** The shared rows of an aggregate example (D60), arranged in their tables before the request. */",
|
|
6256
|
+
`async function arrangeShared(${handleType}, shared: { ${plan.sharedTables.map((table) => `${table.tsName}?: readonly ${table.typeName}[]`).join("; ")} }): Promise<void> {`,
|
|
6257
|
+
...plan.sharedTables.map((table) => ` if (shared.${table.tsName} !== undefined) await arrange${table.typeName}Rows(${handleName}, shared.${table.tsName});`),
|
|
6258
|
+
"}"
|
|
6259
|
+
);
|
|
6260
|
+
}
|
|
6261
|
+
}
|
|
5939
6262
|
if (options.arranging && create !== null) {
|
|
5940
6263
|
imports.push(
|
|
5941
6264
|
plan.target.database === "mssql-raw" ? `import { insert${plan.resource.typeName}, type ${plan.table.rowType} } from "./repository.gen.js";` : `import type { ${plan.table.rowType} } from "./db.schema.gen.js";`,
|
|
@@ -6034,7 +6357,7 @@ function emitContractTests(plan) {
|
|
|
6034
6357
|
);
|
|
6035
6358
|
}
|
|
6036
6359
|
const invalid = operation.kind === "delete" ? { ok: false, reason: "no body" } : invalidSampleOf(operation.inputModel);
|
|
6037
|
-
const invalidConstant = `INVALID_${
|
|
6360
|
+
const invalidConstant = `INVALID_${constantNameOf3(operation.inputType)}`;
|
|
6038
6361
|
const hasInvalid = invalid.ok || operation.kind === "update";
|
|
6039
6362
|
if (hasInvalid) {
|
|
6040
6363
|
factoryImports.add(invalidConstant);
|
|
@@ -6148,7 +6471,14 @@ function behaviorTestLines(plan, item, factoryImports, schemaImports, repository
|
|
|
6148
6471
|
referenced: false,
|
|
6149
6472
|
key: "ARRANGED_KEY",
|
|
6150
6473
|
snapshotBefore: behavior.expectations.some((expectation) => expectation.raw === "rien n'est \xE9crit"),
|
|
6151
|
-
needsReplacement: false
|
|
6474
|
+
needsReplacement: false,
|
|
6475
|
+
sharedExample: null,
|
|
6476
|
+
bindArranged: !operation.report
|
|
6477
|
+
};
|
|
6478
|
+
const examplesOf = (aggregate) => {
|
|
6479
|
+
const constant = `EXAMPLES_OF_${constantNameOf(aggregate.id)}`;
|
|
6480
|
+
useFactory(constant);
|
|
6481
|
+
return constant;
|
|
6152
6482
|
};
|
|
6153
6483
|
const useFactory = (name) => {
|
|
6154
6484
|
factoryImports.add(name);
|
|
@@ -6175,11 +6505,21 @@ function behaviorTestLines(plan, item, factoryImports, schemaImports, repository
|
|
|
6175
6505
|
if (key === `with:${guard.entry.valid.case}`) continue;
|
|
6176
6506
|
if (key === "with:entr\xE9e valide") continue;
|
|
6177
6507
|
if (key === "with:entr\xE9e invalide") {
|
|
6178
|
-
const constant = `INVALID_${
|
|
6508
|
+
const constant = `INVALID_${constantNameOf3(operation.inputType)}`;
|
|
6179
6509
|
useFactory(constant);
|
|
6180
6510
|
derivation.input = constant;
|
|
6181
6511
|
continue;
|
|
6182
6512
|
}
|
|
6513
|
+
const firstAggregate = operation.aggregateHoles[0] ?? null;
|
|
6514
|
+
if (operation.kind === "read" && operation.motif === null && createFactory === null && firstAggregate !== null) {
|
|
6515
|
+
if (key === "with:lignes") {
|
|
6516
|
+
const constant = examplesOf(firstAggregate);
|
|
6517
|
+
derivation.arrangements.push(`...(${constant}[0]?.rows ?? [])`);
|
|
6518
|
+
if (firstAggregate.from.length > 0) derivation.sharedExample = `${constant}[0]?.shared ?? {}`;
|
|
6519
|
+
continue;
|
|
6520
|
+
}
|
|
6521
|
+
if (key === "without:ligne") continue;
|
|
6522
|
+
}
|
|
6183
6523
|
if (operation.kind === "read" && operation.motif === null && createFactory !== null) {
|
|
6184
6524
|
if (key === "with:lignes") {
|
|
6185
6525
|
derivation.arrangements.push(`${createFactory}(0)`, `${createFactory}(1)`);
|
|
@@ -6294,7 +6634,7 @@ function behaviorTestLines(plan, item, factoryImports, schemaImports, repository
|
|
|
6294
6634
|
const refusedByHole = hole !== null && behavior.code !== null && ((hole.kind === "validate" || hole.kind === "transform" || hole.kind === "derive") && hole.errorCode === behavior.code);
|
|
6295
6635
|
if (hole !== null && refusedByHole) {
|
|
6296
6636
|
if ((hole.kind === "validate" || hole.kind === "derive" && hole.reject !== null) && inputFactory !== null) {
|
|
6297
|
-
const constant = `REJECT_EXAMPLE_OF_${
|
|
6637
|
+
const constant = `REJECT_EXAMPLE_OF_${constantNameOf3(hole.id)}`;
|
|
6298
6638
|
useFactory(constant);
|
|
6299
6639
|
derivation.overrides.push(`...${constant}`);
|
|
6300
6640
|
} else if (hole.kind === "transform") {
|
|
@@ -6314,6 +6654,16 @@ function behaviorTestLines(plan, item, factoryImports, schemaImports, repository
|
|
|
6314
6654
|
derivation.needsReplacement = true;
|
|
6315
6655
|
derivation.expectedIds = "[arranged[0]?.id, replacement.id]";
|
|
6316
6656
|
}
|
|
6657
|
+
if (hole !== null && hole.kind === "aggregate") {
|
|
6658
|
+
const constant = examplesOf(hole);
|
|
6659
|
+
derivation.arrangements = [`...(${constant}[0]?.rows ?? [])`];
|
|
6660
|
+
derivation.sharedExample = hole.from.length === 0 ? null : `${constant}[0]?.shared ?? {}`;
|
|
6661
|
+
if (inputFactory === null) derivation.input = `(${constant}[0]?.input ?? {})`;
|
|
6662
|
+
else {
|
|
6663
|
+
useFactory(inputFactory);
|
|
6664
|
+
derivation.input = `{ ...${inputFactory}(), ...(${constant}[0]?.input ?? {}) }`;
|
|
6665
|
+
}
|
|
6666
|
+
}
|
|
6317
6667
|
if (createFactory !== null && derivation.arrangements.length > 0) useFactory(createFactory);
|
|
6318
6668
|
if (inputFactory !== null && derivation.input !== null && derivation.input.startsWith(inputFactory)) useFactory(inputFactory);
|
|
6319
6669
|
if (inputFactory !== null && (derivation.overrides.length > 0 || derivation.queryOverrides.length > 0)) {
|
|
@@ -6330,8 +6680,9 @@ function behaviorTestLines(plan, item, factoryImports, schemaImports, repository
|
|
|
6330
6680
|
}
|
|
6331
6681
|
const handle = arrangingHandle(plan, "member");
|
|
6332
6682
|
if (derivation.arrangements.length > 0) {
|
|
6333
|
-
lines.push(` const arranged = await arrangeRows(${handle}, [${derivation.arrangements.join(", ")}]);`);
|
|
6683
|
+
lines.push(` ${derivation.bindArranged ? "const arranged = " : ""}await arrangeRows(${handle}, [${derivation.arrangements.join(", ")}]);`);
|
|
6334
6684
|
}
|
|
6685
|
+
if (derivation.sharedExample !== null) lines.push(` await arrangeShared(${handle}, ${derivation.sharedExample});`);
|
|
6335
6686
|
if (derivation.needsReplacement && createFactory !== null) {
|
|
6336
6687
|
lines.push(` const replacement = await insert${plan.resource.typeName}(${handle}, ${createFactory}(0, { supersedes: arranged[0]?.id })${plan.signed === null ? "" : ", ARRANGED_SIGNATURE"}, CLOCK);`);
|
|
6337
6688
|
}
|
|
@@ -6442,6 +6793,9 @@ function behaviorTestLines(plan, item, factoryImports, schemaImports, repository
|
|
|
6442
6793
|
const target = signatureTarget();
|
|
6443
6794
|
const expected = value === "renouvel\xE9e" ? `${plan.signature?.exportName ?? "signerOf"}(${derivation.session}.context)` : "ARRANGED_SIGNATURE.by";
|
|
6444
6795
|
lines.push(` expect(${target}?.${plan.signed.by}).toBe(${expected});`);
|
|
6796
|
+
} else if (value.endsWith(" d\xE9riv\xE9") && operation.aggregateHoles.some((candidate) => value === `${candidate.writes} d\xE9riv\xE9`)) {
|
|
6797
|
+
const writer = operation.aggregateHoles.find((candidate) => value === `${candidate.writes} d\xE9riv\xE9`);
|
|
6798
|
+
if (writer !== void 0) lines.push(` expect(output.${subPath}).toEqual(${examplesOf(writer)}[0]?.value);`);
|
|
6445
6799
|
} else if (value.endsWith(" d\xE9riv\xE9") && operation.deriveHoles.some((candidate) => value === `${candidate.writes} d\xE9riv\xE9`)) {
|
|
6446
6800
|
const field = value.slice(0, -" d\xE9riv\xE9".length);
|
|
6447
6801
|
lines.push(` expect(valuesAt(${root}, ${quote(subPath)})).toEqual([DERIVED_OF_VALID_CREATES[0]?.[${quote(field)}]]);`);
|
|
@@ -6502,7 +6856,7 @@ function behaviorTestLines(plan, item, factoryImports, schemaImports, repository
|
|
|
6502
6856
|
return lines;
|
|
6503
6857
|
}
|
|
6504
6858
|
function emitBehaviorTests(plan) {
|
|
6505
|
-
const { imports, helpers } = harnessLines(plan, { arranging: true });
|
|
6859
|
+
const { imports, helpers } = harnessLines(plan, { arranging: true, report: true });
|
|
6506
6860
|
const factoryImports = /* @__PURE__ */ new Set();
|
|
6507
6861
|
const schemaImports = /* @__PURE__ */ new Set();
|
|
6508
6862
|
const repositoryImports = /* @__PURE__ */ new Set();
|
|
@@ -6542,6 +6896,9 @@ function emitBehaviorTests(plan) {
|
|
|
6542
6896
|
}
|
|
6543
6897
|
|
|
6544
6898
|
// src/emit/repository.ts
|
|
6899
|
+
function writtenTypeOf(plan, newType) {
|
|
6900
|
+
return plan.tenant === null ? newType : `Omit<${newType}, ${quote(plan.tenant.columnKey)}>`;
|
|
6901
|
+
}
|
|
6545
6902
|
function comparatorLines(plan, operation) {
|
|
6546
6903
|
const order = operation.order;
|
|
6547
6904
|
if (order === null) return [" return 0;"];
|
|
@@ -6680,7 +7037,7 @@ function insertFunctionLines2(plan, operation) {
|
|
|
6680
7037
|
const signature = signatureParts(plan);
|
|
6681
7038
|
const derivedParameter = derived.length === 0 ? "" : `, derived: Pick<${plan.resource.typeName}, ${derived.map(quote).join(" | ")}>`;
|
|
6682
7039
|
const valuesLines = [
|
|
6683
|
-
` const values:
|
|
7040
|
+
` const values: ${writtenTypeOf(plan, plan.table.newType)} = {`,
|
|
6684
7041
|
" id,",
|
|
6685
7042
|
...inputFieldsPresent.map((field) => ` ${field}: input.${field},`),
|
|
6686
7043
|
...derived.map((field) => ` ${field}: derived.${field},`),
|
|
@@ -6790,7 +7147,7 @@ function insertFunctionLines2(plan, operation) {
|
|
|
6790
7147
|
" 409,",
|
|
6791
7148
|
" );",
|
|
6792
7149
|
" }",
|
|
6793
|
-
` const marking: Partial
|
|
7150
|
+
` const marking: Partial<${writtenTypeOf(plan, plan.table.newType)}> = { supersededBy: id };`,
|
|
6794
7151
|
` await db.update(${table}).set(marking).where(eq(${table}.id, old.id));`,
|
|
6795
7152
|
" } else {",
|
|
6796
7153
|
` const [existing] = await db`,
|
|
@@ -6882,7 +7239,7 @@ function importFunctionLines(plan, operation) {
|
|
|
6882
7239
|
}
|
|
6883
7240
|
lines.push(
|
|
6884
7241
|
" for (const { row } of rows) {",
|
|
6885
|
-
` const values:
|
|
7242
|
+
` const values: ${writtenTypeOf(plan, plan.table.newType)} = {`,
|
|
6886
7243
|
" id: crypto.randomUUID(),",
|
|
6887
7244
|
...valueFields.map((field) => ` ${field}: row.${field},`),
|
|
6888
7245
|
...appendOnly === null ? [] : [" supersedes: null,", " supersededBy: null,"],
|
|
@@ -6919,7 +7276,7 @@ function updateFunctionLines2(plan, operation) {
|
|
|
6919
7276
|
`/** ${operation.id} \u2014 update by key (D28): the fields sent are written, the key never changes${plan.signed === null ? "" : ", the signature is renewed (D31)"}. */`,
|
|
6920
7277
|
`export async function update${plan.resource.typeName}ByKey(ctx: ${plan.context.typeName}, key: ${plan.resource.typeName}[${quote(plan.key.field)}], input: ${operation.inputType}${signature.parameter}): Promise<${plan.table.rowType}> {`,
|
|
6921
7278
|
` const row = await load${plan.resource.typeName}ByKey(ctx, key, ${quote(operation.notFound ?? "")});`,
|
|
6922
|
-
` const patch: Partial
|
|
7279
|
+
` const patch: Partial<${writtenTypeOf(plan, plan.table.newType)}> = {`,
|
|
6923
7280
|
...fields.map((field) => ` ...(input.${field} === undefined ? {} : { ${field}: input.${field} }),`),
|
|
6924
7281
|
...signature.values,
|
|
6925
7282
|
" };",
|
|
@@ -6956,11 +7313,11 @@ function setOrClearFunctionLines2(plan, operation) {
|
|
|
6956
7313
|
" }",
|
|
6957
7314
|
...idempotent ? [` if (existing !== undefined && existing.${slot.value} === value) return "unchanged";`] : [],
|
|
6958
7315
|
" if (existing !== undefined) {",
|
|
6959
|
-
` const patch: Partial
|
|
7316
|
+
` const patch: Partial<${writtenTypeOf(plan, slot.table.newType)}> = { ${slot.value}: value${signature.values.length === 0 ? "" : `, ${plan.signed?.by ?? ""}: signature.by, ${plan.signed?.at ?? ""}: signature.at`} };`,
|
|
6960
7317
|
` await db.update(${table}).set(patch).where(${whereExisting});`,
|
|
6961
7318
|
` return "set";`,
|
|
6962
7319
|
" }",
|
|
6963
|
-
` const values:
|
|
7320
|
+
` const values: ${writtenTypeOf(plan, slot.table.newType)} = {`,
|
|
6964
7321
|
...slot.table.hasId ? [" id: crypto.randomUUID(),"] : [],
|
|
6965
7322
|
` ${slot.parentKey}: parent.${plan.key.field},`,
|
|
6966
7323
|
` ${slot.key}: slotKey,`,
|
|
@@ -7027,13 +7384,46 @@ function referenceLines2(plan, reference) {
|
|
|
7027
7384
|
"",
|
|
7028
7385
|
`/** One ${reference.name} row pointing at a resource key \u2014 what a test arranges to prove \xAB ligne r\xE9f\xE9renc\xE9e \xBB. */`,
|
|
7029
7386
|
`export async function insert${typeName}(ctx: ${plan.context.typeName}, values: Pick<${reference.table.newType}, ${writtenColumns2.map(quote).join(" | ")}>): Promise<${reference.table.rowType}> {`,
|
|
7030
|
-
` const inserted:
|
|
7387
|
+
` const inserted: ${writtenTypeOf(plan, reference.table.newType)} = { ...values };`,
|
|
7031
7388
|
` const [row] = await tenantDb(ctx).insert(${reference.table.tsName}).values(inserted).returning();`,
|
|
7032
7389
|
" if (row === undefined) throwOgygieError(insertReturnedNothing());",
|
|
7033
7390
|
" return row;",
|
|
7034
7391
|
"}"
|
|
7035
7392
|
];
|
|
7036
7393
|
}
|
|
7394
|
+
function sharedReadLines2(plan, table) {
|
|
7395
|
+
return [
|
|
7396
|
+
`/** The rows of the shared table ${table.sqlName} (D60) for the tenant, through its read-only mapping \u2014 what the aggregate holes receive, at their schema. */`,
|
|
7397
|
+
`export async function select${table.typeName}Rows(ctx: ${plan.context.typeName}): Promise<${table.rowType}[]> {`,
|
|
7398
|
+
` return tenantDb(ctx).select(${table.tsName}).all();`,
|
|
7399
|
+
"}"
|
|
7400
|
+
];
|
|
7401
|
+
}
|
|
7402
|
+
function insertExpressionOf(field) {
|
|
7403
|
+
if (field.model.kind === "string" && field.model.format === "datetime") {
|
|
7404
|
+
return field.model.nullable ? `row.${field.name} === null ? null : new Date(row.${field.name})` : `new Date(row.${field.name})`;
|
|
7405
|
+
}
|
|
7406
|
+
return `row.${field.name}`;
|
|
7407
|
+
}
|
|
7408
|
+
function arrangeLines2(plan, target) {
|
|
7409
|
+
const valuesType = target.newType === null ? `typeof ${target.tsName}.$inferInsert` : target.newType;
|
|
7410
|
+
return [
|
|
7411
|
+
`/** TESTS ONLY (D60): rows of ${target.sqlTable} arranged at their schema \u2014 the feature never writes them, they come from elsewhere; the tests of a block without create put their rows on the bench here. */`,
|
|
7412
|
+
`export async function arrange${target.typeName}Rows(ctx: ${plan.context.typeName}, rows: readonly ${target.withId ? `Omit<${target.typeName}, "id">` : target.typeName}[]): Promise<${target.rowType}[]> {`,
|
|
7413
|
+
` const inserted: ${target.rowType}[] = [];`,
|
|
7414
|
+
" for (const row of rows) {",
|
|
7415
|
+
` const values: ${writtenTypeOf(plan, valuesType)} = {`,
|
|
7416
|
+
...target.withId ? [" id: crypto.randomUUID(),"] : [],
|
|
7417
|
+
...target.fields.filter((field) => !(target.withId && field.name === "id")).map((field) => ` ${field.name}: ${insertExpressionOf(field)},`),
|
|
7418
|
+
" };",
|
|
7419
|
+
` const [written] = await tenantDb(ctx).insert(${target.tsName}).values(values).returning();`,
|
|
7420
|
+
" if (written === undefined) throwOgygieError(insertReturnedNothing());",
|
|
7421
|
+
" inserted.push(written);",
|
|
7422
|
+
" }",
|
|
7423
|
+
" return inserted;",
|
|
7424
|
+
"}"
|
|
7425
|
+
];
|
|
7426
|
+
}
|
|
7037
7427
|
function viewReadLines2(plan, view) {
|
|
7038
7428
|
return [
|
|
7039
7429
|
`/** The rows of the view ${view.name} (hole ${view.id}, D32) for the tenant, through its read-only mapping. */`,
|
|
@@ -7064,34 +7454,44 @@ function emitRepository(plan) {
|
|
|
7064
7454
|
}
|
|
7065
7455
|
const queryTypes = reads.filter((operation) => operation.motif?.method === "currentByKey").map((operation) => operation.inputType);
|
|
7066
7456
|
const inputTypes = [...new Set([create?.inputType, update?.inputType, plan.importMotif?.rowType, ...queryTypes].filter((name) => name !== void 0))].sort();
|
|
7067
|
-
const
|
|
7457
|
+
const arranging = create === void 0 && plan.operations.some((operation) => operation.aggregateHoles.length > 0);
|
|
7458
|
+
const resourceTypes = [...new Set([plan.key === null ? null : plan.resource.typeName, ...arranging ? [plan.resource.typeName, ...plan.sharedTables.map((table) => table.typeName)] : [], ...setOrClear?.slot === null || setOrClear?.slot === void 0 ? [] : [setOrClear.slot.typeName]].filter((name) => name !== null))].sort();
|
|
7068
7459
|
const tableImports = [`${plan.table.tsName}, type ${plan.table.newType}, type ${plan.table.rowType}`];
|
|
7069
7460
|
for (const slot of plan.slots) tableImports.push(`${slot.table.tsName}, type ${slot.table.newType}, type ${slot.table.rowType}`);
|
|
7070
7461
|
for (const reference of plan.references) if (reference.table !== null) tableImports.push(`${reference.table.tsName}, type ${reference.table.newType}, type ${reference.table.rowType}`);
|
|
7071
7462
|
for (const view of views) tableImports.push(`${view.tsName}, type ${view.rowType}`);
|
|
7463
|
+
for (const table of plan.sharedTables) tableImports.push(`${table.tsName}, type ${table.rowType}`);
|
|
7072
7464
|
const sections = [];
|
|
7073
7465
|
for (const operation of reads) sections.push("", ...readFunctionLines2(plan, operation));
|
|
7074
7466
|
for (const slot of plan.slots) sections.push("", ...slotReadLines2(plan, slot));
|
|
7075
7467
|
for (const reference of plan.references) sections.push("", ...referenceLines2(plan, reference));
|
|
7076
7468
|
for (const view of views) sections.push("", ...viewReadLines2(plan, view));
|
|
7469
|
+
for (const table of plan.sharedTables) sections.push("", ...sharedReadLines2(plan, table));
|
|
7470
|
+
if (create === void 0 && plan.operations.some((operation) => operation.aggregateHoles.length > 0)) {
|
|
7471
|
+
sections.push("", ...arrangeLines2(plan, { sqlTable: plan.table.sqlName, tsName: plan.table.tsName, rowType: plan.table.rowType, newType: plan.table.newType, typeName: plan.resource.typeName, fields: plan.resource.fields, withId: true }));
|
|
7472
|
+
for (const table of plan.sharedTables) sections.push("", ...arrangeLines2(plan, { sqlTable: table.sqlName, tsName: table.tsName, rowType: table.rowType, newType: null, typeName: table.typeName, fields: table.fields, withId: false }));
|
|
7473
|
+
}
|
|
7077
7474
|
if (plan.key !== null) sections.push("", ...loadByKeyLines2(plan));
|
|
7078
7475
|
if (create !== void 0) sections.push("", ...insertFunctionLines2(plan, create));
|
|
7079
7476
|
if (update !== void 0) sections.push("", ...updateFunctionLines2(plan, update));
|
|
7080
7477
|
if (setOrClear !== void 0) sections.push("", ...setOrClearFunctionLines2(plan, setOrClear));
|
|
7081
7478
|
if (deleteOperation !== void 0) sections.push("", ...deleteFunctionLines2(plan, deleteOperation));
|
|
7082
7479
|
if (importOperation !== void 0) sections.push("", ...importFunctionLines(plan, importOperation));
|
|
7480
|
+
const noTenant = plan.tenant === null;
|
|
7481
|
+
const handleType = noTenant ? plan.databasePort?.typeName ?? "Database" : plan.context.typeName;
|
|
7482
|
+
const finalSections = noTenant ? sections.map((line) => line.replace(/\btenantDb\(ctx\)/gu, "plainDb(db)").replace(/\bctx\b/gu, "db").replaceAll(`db: ${plan.context.typeName}`, `db: ${handleType}`)) : sections;
|
|
7083
7483
|
return fileOf(
|
|
7084
7484
|
tsGeneratedHeader(plan.generatorVersion),
|
|
7085
7485
|
intentBlock(
|
|
7086
7486
|
wrap(
|
|
7087
|
-
`Persistence of the ${plan.featureId} feature \u2014 the only file of the feature allowed to touch the database, always through tenantDb: the tenant column is injected on insert and filtered on every read, never written by hand (AGENTS.md \xA7Multi-tenant).${plan.appendOnly === null ? "" : ` The append-only motif appendOnlySupersedes on (${plan.appendOnly.key.join(", ")}): current rows per key, supersede by marking the old row BEFORE inserting the new one.`}${plan.importMotif === null ? "" : " The all-or-nothing import: one transaction or nothing."}${plan.key === null ? "" : ` The keyed writes of D28 address a row by ${plan.key.field} and answer the operation's 404 otherwise.`}${plan.unique === null ? "" : ` uniqueKey: 409 ${plan.unique.error} on a taken key.`}${plan.referenced === null ? "" : ` refuseWhenReferenced: 409 ${plan.referenced.error} while a ${plan.referenced.reference.name} row points at the row.`}${plan.signed === null ? "" : ` signedWrite: ${plan.signed.by} and ${plan.signed.at} on every write.`}${views.length === 0 ? "" : ` The views ${views.map((view) => view.name).join(", ")} are read through their read-only mappings (D32).`} Derived from ${plan.specPath}.`
|
|
7487
|
+
`Persistence of the ${plan.featureId} feature \u2014 the only file of the feature allowed to touch the database, ${noTenant ? "always through plainDb: the product is single-tenant (target.tenancy none, D33), the whole table is read and written through the one facade, never a raw query builder" : "always through tenantDb: the tenant column is injected on insert and filtered on every read, never written by hand (AGENTS.md \xA7Multi-tenant)"}.${plan.appendOnly === null ? "" : ` The append-only motif appendOnlySupersedes on (${plan.appendOnly.key.join(", ")}): current rows per key, supersede by marking the old row BEFORE inserting the new one.`}${plan.importMotif === null ? "" : " The all-or-nothing import: one transaction or nothing."}${plan.key === null ? "" : ` The keyed writes of D28 address a row by ${plan.key.field} and answer the operation's 404 otherwise.`}${plan.unique === null ? "" : ` uniqueKey: 409 ${plan.unique.error} on a taken key.`}${plan.referenced === null ? "" : ` refuseWhenReferenced: 409 ${plan.referenced.error} while a ${plan.referenced.reference.name} row points at the row.`}${plan.signed === null ? "" : ` signedWrite: ${plan.signed.by} and ${plan.signed.at} on every write.`}${views.length === 0 ? "" : ` The views ${views.map((view) => view.name).join(", ")} are read through their read-only mappings (D32).`} Derived from ${plan.specPath}.`
|
|
7088
7488
|
)
|
|
7089
7489
|
),
|
|
7090
7490
|
drizzleImports.size === 0 ? null : `import { ${[...drizzleImports].sort().join(", ")} } from "drizzle-orm";`,
|
|
7091
7491
|
'import type { Clock } from "@ogygie/core/clock";',
|
|
7092
7492
|
'import { throwOgygieError } from "@ogygie/core/errors";',
|
|
7093
|
-
'import { tenantDb } from "@ogygie/core/tenant";',
|
|
7094
|
-
`import type { ${plan.context.typeName} } from ${quote(plan.context.importPath)};`,
|
|
7493
|
+
noTenant ? 'import { plainDb } from "@ogygie/core/tenant";' : 'import { tenantDb } from "@ogygie/core/tenant";',
|
|
7494
|
+
noTenant ? `import type { ${handleType} } from ${quote(plan.databasePort?.importPath ?? "../../db.js")};` : `import type { ${plan.context.typeName} } from ${quote(plan.context.importPath)};`,
|
|
7095
7495
|
"import {",
|
|
7096
7496
|
...tableImports.map((line) => ` ${line},`),
|
|
7097
7497
|
'} from "./db.schema.gen.js";',
|
|
@@ -7126,7 +7526,7 @@ function emitRepository(plan) {
|
|
|
7126
7526
|
` suggestedFix: "Check the migration ${plan.migrationFileName} was applied to this database.",`,
|
|
7127
7527
|
" };",
|
|
7128
7528
|
"}",
|
|
7129
|
-
...
|
|
7529
|
+
...finalSections
|
|
7130
7530
|
);
|
|
7131
7531
|
}
|
|
7132
7532
|
|
|
@@ -7340,7 +7740,51 @@ function emitService(plan) {
|
|
|
7340
7740
|
const signatureLine = plan.signed === null ? [] : [` const signature: WriteSignature = { by: ${plan.signature?.exportName ?? "signerOf"}(deps.ctx), at: deps.clock.now() };`];
|
|
7341
7741
|
const signatureArgument = plan.signed === null ? "" : ", signature";
|
|
7342
7742
|
if (plan.signed !== null) repositoryImports.add("type WriteSignature");
|
|
7743
|
+
const projectionLines = (operation) => {
|
|
7744
|
+
if (operation.projectHoles.length === 0) return [" return computed;"];
|
|
7745
|
+
const lines = [];
|
|
7746
|
+
if (plan.tenant === null) lines.push(" const projection = deps.ctx;");
|
|
7747
|
+
else lines.push(" const { db: _db, ...projection } = deps.ctx;", " void _db;");
|
|
7748
|
+
let current = "computed";
|
|
7749
|
+
for (const hole of operation.projectHoles) {
|
|
7750
|
+
lines.push(` const ${hole.id}Output = ${hole.id}(projection, ${current});`);
|
|
7751
|
+
current = `${hole.id}Output`;
|
|
7752
|
+
}
|
|
7753
|
+
lines.push(` return ${current};`);
|
|
7754
|
+
return lines;
|
|
7755
|
+
};
|
|
7756
|
+
const reportBodyLines = (operation) => {
|
|
7757
|
+
const selectName = `select${operation.pascalId}`;
|
|
7758
|
+
repositoryImports.add(selectName);
|
|
7759
|
+
const lines = [` const rows = (await ${selectName}(${handle})).map(${dto});`];
|
|
7760
|
+
const sharedUsed = /* @__PURE__ */ new Map();
|
|
7761
|
+
for (const hole of operation.aggregateHoles) for (const table of hole.from) sharedUsed.set(table.sqlName, table);
|
|
7762
|
+
for (const table of sharedUsed.values()) {
|
|
7763
|
+
repositoryImports.add(`select${table.typeName}Rows`);
|
|
7764
|
+
dbImports.add(`type ${table.rowType}`);
|
|
7765
|
+
typeImports.add(table.typeName);
|
|
7766
|
+
lines.push(` const ${table.tsName}Rows = (await select${table.typeName}Rows(${handle})).map(to${table.typeName}Dto);`);
|
|
7767
|
+
}
|
|
7768
|
+
for (const hole of operation.aggregateHoles) {
|
|
7769
|
+
const picked = hole.reads.map((field) => `${field}: input.${field}`).join(", ");
|
|
7770
|
+
const shared = hole.from.length === 0 ? "" : `, { ${hole.from.map((table) => `${table.tsName}: ${table.tsName}Rows`).join(", ")} }`;
|
|
7771
|
+
const call = `${hole.id}({ ${picked} }, rows${shared}${effectArguments(plan, hole)})`;
|
|
7772
|
+
lines.push(` const ${hole.id}Value = ${isAsyncHole(hole) ? `await ${call}` : call};`);
|
|
7773
|
+
}
|
|
7774
|
+
const rootFields = [];
|
|
7775
|
+
if (operation.outputModel.kind === "object") {
|
|
7776
|
+
for (const [name, model] of Object.entries(operation.outputModel.fields)) {
|
|
7777
|
+
const hole = operation.aggregateHoles.find((candidate) => candidate.writes === name);
|
|
7778
|
+
if (hole !== void 0) rootFields.push(` ${name}: ${hole.id}Value,`);
|
|
7779
|
+
else if (operation.echoFields.includes(name)) rootFields.push(` ${name}: input.${name},`);
|
|
7780
|
+
else rootFields.push(` ${name}: ${neutralValueOf(model)},`);
|
|
7781
|
+
}
|
|
7782
|
+
}
|
|
7783
|
+
lines.push(` const computed: ${operation.outputType} = {`, ...rootFields, " };", ...projectionLines(operation));
|
|
7784
|
+
return lines;
|
|
7785
|
+
};
|
|
7343
7786
|
const readBodyLines = (operation) => {
|
|
7787
|
+
if (operation.report) return reportBodyLines(operation);
|
|
7344
7788
|
const element = operation.element;
|
|
7345
7789
|
if (element === null) {
|
|
7346
7790
|
const selectName2 = `select${operation.pascalId}`;
|
|
@@ -7400,20 +7844,9 @@ function emitService(plan) {
|
|
|
7400
7844
|
...elementFields,
|
|
7401
7845
|
" })),",
|
|
7402
7846
|
...rootFields,
|
|
7403
|
-
" };"
|
|
7847
|
+
" };",
|
|
7848
|
+
...projectionLines(operation)
|
|
7404
7849
|
);
|
|
7405
|
-
if (operation.projectHoles.length === 0) {
|
|
7406
|
-
lines.push(" return computed;");
|
|
7407
|
-
} else {
|
|
7408
|
-
if (plan.tenant === null) lines.push(" const projection = deps.ctx;");
|
|
7409
|
-
else lines.push(" const { db: _db, ...projection } = deps.ctx;", " void _db;");
|
|
7410
|
-
let current = "computed";
|
|
7411
|
-
for (const hole of operation.projectHoles) {
|
|
7412
|
-
lines.push(` const ${hole.id}Output = ${hole.id}(projection, ${current});`);
|
|
7413
|
-
current = `${hole.id}Output`;
|
|
7414
|
-
}
|
|
7415
|
-
lines.push(` return ${current};`);
|
|
7416
|
-
}
|
|
7417
7850
|
return lines;
|
|
7418
7851
|
};
|
|
7419
7852
|
const respondLines = (operation) => {
|
|
@@ -7429,7 +7862,7 @@ function emitService(plan) {
|
|
|
7429
7862
|
"",
|
|
7430
7863
|
`/** ${operation.method} ${operation.path} \u2014 ${operation.id}. */`,
|
|
7431
7864
|
`export async function ${operation.serviceName}(deps: ${deps}, input: ${operation.inputType}): Promise<${operation.outputType}> {`,
|
|
7432
|
-
...operation.echoFields.length === 0 && operation.motif?.method !== "currentByKey" ? [" void input;"] : [],
|
|
7865
|
+
...operation.echoFields.length === 0 && operation.motif?.method !== "currentByKey" && !operation.aggregateHoles.some((hole) => hole.reads.length > 0) ? [" void input;"] : [],
|
|
7433
7866
|
...readBodyLines(operation),
|
|
7434
7867
|
"}"
|
|
7435
7868
|
);
|
|
@@ -7588,6 +8021,16 @@ function emitService(plan) {
|
|
|
7588
8021
|
" };",
|
|
7589
8022
|
"}"
|
|
7590
8023
|
];
|
|
8024
|
+
const sharedUsedByHoles = plan.sharedTables.filter((table) => plan.holes.some((hole) => hole.kind === "aggregate" && hole.from.some((candidate) => candidate.sqlName === table.sqlName)));
|
|
8025
|
+
const sharedDtoLines = sharedUsedByHoles.flatMap((table) => [
|
|
8026
|
+
"",
|
|
8027
|
+
`/** One row of the shared table ${table.sqlName} at its schema (D60): what the aggregate holes receive. */`,
|
|
8028
|
+
`function to${table.typeName}Dto(row: ${table.rowType}): ${table.typeName} {`,
|
|
8029
|
+
" return {",
|
|
8030
|
+
...table.fields.map((field) => ` ${field.name}: ${field.dtoExpression},`),
|
|
8031
|
+
" };",
|
|
8032
|
+
"}"
|
|
8033
|
+
]);
|
|
7591
8034
|
const slotHelpers = plan.slots.flatMap((slot) => {
|
|
7592
8035
|
const dtoFields = slot.fields.map((field) => `${field.name}: ${field.dtoExpression}`).join(", ");
|
|
7593
8036
|
const keyValues = slot.keyValues === null ? null : `KEY_VALUES_OF_${slot.name.toUpperCase()}`;
|
|
@@ -7636,6 +8079,7 @@ ${[...typeImports].sort().map((name) => ` type ${name},`).join("\n")}
|
|
|
7636
8079
|
"}",
|
|
7637
8080
|
"",
|
|
7638
8081
|
...dtoLines,
|
|
8082
|
+
...sharedDtoLines,
|
|
7639
8083
|
...slotHelpers,
|
|
7640
8084
|
"",
|
|
7641
8085
|
"/** A hole's refusal becomes the operation's 400 OgygieError. */",
|
|
@@ -7758,7 +8202,8 @@ function manifestEntryOf(plan) {
|
|
|
7758
8202
|
})),
|
|
7759
8203
|
// The views of the view holes are read through Drizzle like tables: what the repository reads is on the map (D32).
|
|
7760
8204
|
// D33 precision (M3): the manifest gate proves a declared table by its pgTable — a SQL Server product has none, so its entry declares no table; the generated block of its spec is the map.
|
|
7761
|
-
|
|
8205
|
+
// D60: a shared table read through its Drizzle mapping is on the map like a view — the manifest gate asks every pgTable of the folder to be declared.
|
|
8206
|
+
dbTables: plan.target.database === "mssql-raw" ? [] : [...tablesOf(plan).map((table) => table.sqlName), ...plan.holes.flatMap((hole) => hole.kind === "view" ? [hole.name] : []), ...plan.sharedTables.map((table) => table.sqlName)]
|
|
7762
8207
|
};
|
|
7763
8208
|
}
|
|
7764
8209
|
function canonicalJson(value) {
|
|
@@ -7990,6 +8435,8 @@ function holeLine(hole, plan) {
|
|
|
7990
8435
|
return `- ${tick3(hole.id)} \u2014 ${tick3("validate")}, lit ${hole.reads.map(tick3).join(", ")} de ${tick3(hole.inputTypeName)}, refuse en ${tick3(hole.errorCode)} (400) ; appel\xE9 par ${carriers} ; ${context}`;
|
|
7991
8436
|
case "derive":
|
|
7992
8437
|
return `- ${tick3(hole.id)} \u2014 ${tick3("derive")}, lit ${hole.reads.map(tick3).join(", ")} de ${tick3(hole.inputTypeName)}, \xE9crit ${tick3(hole.writes)}${hole.errorCode === null ? "" : `, refuse en ${tick3(hole.errorCode)} (400)`} ; appel\xE9 par ${carriers} ; ${context}`;
|
|
8438
|
+
case "aggregate":
|
|
8439
|
+
return `- ${tick3(hole.id)} \u2014 ${tick3("aggregate")} (D60), lit ${hole.reads.length === 0 ? "rien de l'entr\xE9e" : `${hole.reads.map(tick3).join(", ")} de ${tick3(hole.inputTypeName)}`}, les lignes de ${tick3(hole.resourceTypeName)}${hole.from.length === 0 ? "" : ` et ${hole.from.map((table) => tick3(table.sqlName)).join(", ")}`}, \xE9crit ${tick3(hole.writes)} de la sortie ; appel\xE9 par ${carriers} ; ${context}`;
|
|
7993
8440
|
case "project":
|
|
7994
8441
|
return `- ${tick3(hole.id)} \u2014 ${tick3("project")}, lit ${hole.reads.map(tick3).join(", ")} du contexte de l'appelant, \xE9crit ${hole.writes.map(tick3).join(", ")} ; appel\xE9 par ${carriers} ; ${context}`;
|
|
7995
8442
|
case "view":
|
|
@@ -8037,6 +8484,7 @@ function emitSpecContracts(plan) {
|
|
|
8037
8484
|
...tableSection(plan.table, plan, indexes.length === 0 ? [] : [`index ${indexes.join(" ; ")}`]),
|
|
8038
8485
|
...detailTables.flatMap((table) => ["", ...tableSection(table, plan, [])]),
|
|
8039
8486
|
...views.flatMap((view) => ["", ...viewSection(view)]),
|
|
8487
|
+
...plan.sharedTables.flatMap((table) => ["", `${tick3(table.sqlName)} \u2014 table PARTAG\xC9E (D60) : table du produit hors du bloc, lue par les trous aggregate qui la nomment, typ\xE9e par ${tick3(table.schemaName)}, jamais cr\xE9\xE9e ni \xE9crite ici ; mapping en lecture seule dans ${tick3("db.schema.gen.ts")}.`]),
|
|
8040
8488
|
"",
|
|
8041
8489
|
"## Trous (D23)",
|
|
8042
8490
|
"",
|
|
@@ -11504,10 +11952,14 @@ export {
|
|
|
11504
11952
|
tableNameOf,
|
|
11505
11953
|
tableRefOf,
|
|
11506
11954
|
parentKeyFieldOf,
|
|
11955
|
+
SharedTableSchema,
|
|
11956
|
+
sharedTableNameOf,
|
|
11957
|
+
typedSharedTablesOf,
|
|
11507
11958
|
OperationSchema,
|
|
11508
11959
|
isHttpOperation,
|
|
11509
11960
|
isWriteOperation,
|
|
11510
11961
|
isKeyedOperation,
|
|
11962
|
+
AggregateHoleSchema,
|
|
11511
11963
|
HoleSchema,
|
|
11512
11964
|
acceptExamplesOf,
|
|
11513
11965
|
HOLE_KIND_SERVES,
|
|
@@ -11588,6 +12040,7 @@ export {
|
|
|
11588
12040
|
tablesOf,
|
|
11589
12041
|
ownedTables,
|
|
11590
12042
|
viewBodiesOf,
|
|
12043
|
+
deepOverlay,
|
|
11591
12044
|
normalizedTypeOf,
|
|
11592
12045
|
HoleShapeSchema,
|
|
11593
12046
|
HoleProvenanceSchema,
|
|
@@ -11603,7 +12056,6 @@ export {
|
|
|
11603
12056
|
HOLES_LIBRARY_DIR,
|
|
11604
12057
|
loadHolesLibrary,
|
|
11605
12058
|
libraryFileNameOf,
|
|
11606
|
-
deepOverlay,
|
|
11607
12059
|
GENERATED_FEATURE_FILES,
|
|
11608
12060
|
generateFeature,
|
|
11609
12061
|
migrationFileNameFor,
|