@sincpro/client-framework 0.4.0 → 0.6.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.
Files changed (46) hide show
  1. package/README.md +59 -9
  2. package/dist/adapters/parquet.d.ts +50 -0
  3. package/dist/adapters/parquet.js +151 -0
  4. package/dist/client/entity.d.ts +16 -1
  5. package/dist/client/entity.js +16 -1
  6. package/dist/client/memory.js +10 -6
  7. package/dist/client/rest.d.ts +7 -1
  8. package/dist/client/rest.js +46 -1
  9. package/dist/client/transport.d.ts +37 -1
  10. package/dist/client/transport.js +6 -0
  11. package/dist/codegen/check.d.ts +64 -0
  12. package/dist/codegen/check.js +195 -0
  13. package/dist/codegen/cli.js +73 -6
  14. package/dist/codegen/client.js +30 -9
  15. package/dist/codegen/domain.d.ts +2 -1
  16. package/dist/codegen/domain.js +63 -10
  17. package/dist/codegen/entities.d.ts +2 -1
  18. package/dist/codegen/entities.js +28 -5
  19. package/dist/codegen/index.d.ts +2 -0
  20. package/dist/codegen/index.js +2 -0
  21. package/dist/codegen/metamodel.d.ts +4 -0
  22. package/dist/codegen/names.d.ts +12 -0
  23. package/dist/codegen/names.js +58 -0
  24. package/dist/codegen/scaffold.d.ts +24 -0
  25. package/dist/codegen/scaffold.js +123 -0
  26. package/dist/common/collection.d.ts +110 -0
  27. package/dist/common/collection.js +169 -0
  28. package/dist/common/decimal.js +8 -0
  29. package/dist/common/serialization.d.ts +5 -1
  30. package/dist/common/serialization.js +17 -4
  31. package/dist/common/time.d.ts +2 -1
  32. package/dist/common/time.js +9 -2
  33. package/dist/context/codec.d.ts +2 -0
  34. package/dist/context/codec.js +16 -0
  35. package/dist/index.d.ts +8 -4
  36. package/dist/index.js +8 -4
  37. package/dist/model/meta.d.ts +1 -0
  38. package/dist/observability/ports.d.ts +2 -1
  39. package/dist/services/bounded-context.js +4 -15
  40. package/dist/services/execution.js +5 -2
  41. package/dist/services/feature.d.ts +4 -1
  42. package/dist/services/report.d.ts +15 -0
  43. package/dist/services/report.js +53 -0
  44. package/dist/services/screens.d.ts +2 -0
  45. package/dist/services/screens.js +76 -0
  46. package/package.json +16 -3
@@ -3,8 +3,10 @@
3
3
  * sincpro_framework exports (PRD_01 phase B). A build step that runs in Node — nothing in
4
4
  * the runtime imports it (`make doctor`), and the package's root entry does not re-export it. `sincpro-client generate` (`cli.ts`) is the same, from a terminal.
5
5
  */
6
+ export { CheckError, checkImports, type Place, placeOf, type Violation, violationOf, } from "./check.js";
6
7
  export { clientFileOf } from "./client.js";
7
8
  export { domainFileOf } from "./domain.js";
8
9
  export { entitiesFileOf } from "./entities.js";
9
10
  export type { Bindings, Metamodel, Operation, RestBinding, Schema, } from "./metamodel.js";
10
11
  export type { GenerateOptions } from "./rest.js";
12
+ export { scaffoldFilesOf, type ScaffoldOptions, } from "./scaffold.js";
@@ -3,6 +3,8 @@
3
3
  * sincpro_framework exports (PRD_01 phase B). A build step that runs in Node — nothing in
4
4
  * the runtime imports it (`make doctor`), and the package's root entry does not re-export it. `sincpro-client generate` (`cli.ts`) is the same, from a terminal.
5
5
  */
6
+ export { CheckError, checkImports, placeOf, violationOf, } from "./check.js";
6
7
  export { clientFileOf } from "./client.js";
7
8
  export { domainFileOf } from "./domain.js";
8
9
  export { entitiesFileOf } from "./entities.js";
10
+ export { scaffoldFilesOf, } from "./scaffold.js";
@@ -26,7 +26,11 @@ export type Schema = {
26
26
  type?: string;
27
27
  relation?: string | null;
28
28
  identity?: string;
29
+ /** A to-many whose records are other aggregates: listed and searched, never edited as lines. */
30
+ reference?: boolean;
29
31
  };
32
+ /** An answer that is bytes as they are produced (an export): the media types it may be. */
33
+ "x-sincpro-streamed"?: readonly string[];
30
34
  };
31
35
  export type Operation = {
32
36
  /** What it is for its entity: `search`, `get`, `edit`, `command`… */
@@ -21,6 +21,18 @@ export declare function aliasOf(metamodel: Metamodel): string;
21
21
  export declare function isEntity(metamodel: Metamodel, model: string): boolean;
22
22
  /** An operation's name in its context: its identity without `<bounded_context>.`. */
23
23
  export declare function localOf(metamodel: Metamodel, identity: string): string;
24
+ /**
25
+ * What an operation's answer is called: the backend's own name for it — its output's title,
26
+ * `ResponseCancelIssue` — when that name is one type (every output with it is the same schema)
27
+ * and no other type has it; `<Name>Answer` when the output has no name of its own (a model, a
28
+ * list) or two different shapes share it. A new operation answering with the same Response keeps
29
+ * the name: regenerating never renames by itself.
30
+ *
31
+ * Context: the ubiquitous language. The backend writes `CommandCancelIssue` → `ResponseCancelIssue`,
32
+ * and a client that calls it `CommandCancelIssueAnswer` makes a person learn two words for one
33
+ * thing. The `<Name>Answer` it used to write stays as a deprecated alias of the backend's name.
34
+ */
35
+ export declare function answerOf(metamodel: Metamodel, identity: string): string;
24
36
  /** The method an operation is called by: `CommandSendIssue` → `sendIssue`. */
25
37
  export declare function methodOf(local: string): string;
26
38
  /** `Issue` → `issues`, `ProjectEvent` → `projectEvents`, `Policy` → `policies`. */
@@ -52,6 +52,64 @@ export function localOf(metamodel, identity) {
52
52
  const prefix = `${contextOf(metamodel)}.`;
53
53
  return identifierOf(identity.startsWith(prefix) ? identity.slice(prefix.length) : identity);
54
54
  }
55
+ const ANSWERS = new WeakMap();
56
+ /** The output's own name, when it has one a TypeScript type can take. */
57
+ function titleOf(output) {
58
+ if (output === null || output.$ref !== undefined)
59
+ return undefined;
60
+ const title = output.title;
61
+ return typeof title === "string" && /^[A-Za-z_$][\w$]*$/.test(title) ? title : undefined;
62
+ }
63
+ function answersOf(metamodel) {
64
+ const operations = Object.entries(metamodel.operations);
65
+ const taken = new Set([
66
+ ...Object.keys(metamodel.models).map(identifierOf),
67
+ ...Object.keys(metamodel.$defs ?? {}).map(identifierOf),
68
+ ]);
69
+ for (const [identity] of operations) {
70
+ const local = localOf(metamodel, identity);
71
+ taken.add(local);
72
+ taken.add(`${local}Answer`);
73
+ }
74
+ // A title is one type when every output that carries it is the same schema — the backend
75
+ // answering two Commands with one Response class; two different schemas under one title are
76
+ // two types, and neither takes the name.
77
+ const shapes = new Map();
78
+ for (const [, operation] of operations) {
79
+ const title = titleOf(operation.output);
80
+ if (title === undefined)
81
+ continue;
82
+ const seen = shapes.get(title) ?? new Set();
83
+ seen.add(JSON.stringify(operation.output));
84
+ shapes.set(title, seen);
85
+ }
86
+ const names = new Map();
87
+ for (const [identity, operation] of operations) {
88
+ const title = titleOf(operation.output);
89
+ if (title !== undefined && shapes.get(title)?.size === 1 && !taken.has(title))
90
+ names.set(identity, title);
91
+ }
92
+ return names;
93
+ }
94
+ /**
95
+ * What an operation's answer is called: the backend's own name for it — its output's title,
96
+ * `ResponseCancelIssue` — when that name is one type (every output with it is the same schema)
97
+ * and no other type has it; `<Name>Answer` when the output has no name of its own (a model, a
98
+ * list) or two different shapes share it. A new operation answering with the same Response keeps
99
+ * the name: regenerating never renames by itself.
100
+ *
101
+ * Context: the ubiquitous language. The backend writes `CommandCancelIssue` → `ResponseCancelIssue`,
102
+ * and a client that calls it `CommandCancelIssueAnswer` makes a person learn two words for one
103
+ * thing. The `<Name>Answer` it used to write stays as a deprecated alias of the backend's name.
104
+ */
105
+ export function answerOf(metamodel, identity) {
106
+ let known = ANSWERS.get(metamodel);
107
+ if (known === undefined) {
108
+ known = answersOf(metamodel);
109
+ ANSWERS.set(metamodel, known);
110
+ }
111
+ return known.get(identity) ?? `${localOf(metamodel, identity)}Answer`;
112
+ }
55
113
  /** The method an operation is called by: `CommandSendIssue` → `sendIssue`. */
56
114
  export function methodOf(local) {
57
115
  const bare = local.replace(/^(Command|Query)(?=[A-Z])/, "");
@@ -0,0 +1,24 @@
1
+ import type { Metamodel } from "./metamodel.js";
2
+ /**
3
+ * A bounded context's skeleton, written ONCE beside what is generated — the files that are the
4
+ * application's from then on:
5
+ *
6
+ * domains/project/
7
+ * generated/ `sincpro-client generate` — regenerated, never edited
8
+ * infrastructure/dependencies.ts what the services can count on: the generated client
9
+ * infrastructure/bounded_context.ts its Feature and ApplicationService, and how it is made
10
+ * services/index.ts the services, by the name each is reached by
11
+ * index.ts `export const project` — ready to use
12
+ *
13
+ * Context: the layout sincpro_framework gives a service (`dependencies.py`, `framework.py`,
14
+ * `services/`, `__init__.py`), so a bounded context starts the way every other does and nobody
15
+ * copies it by hand from another. It is the application's to change: the CLI writes a file only
16
+ * when it is not there, and never touches one that is — unlike `generated/`, which is written
17
+ * whole every time.
18
+ */
19
+ export type ScaffoldOptions = {
20
+ /** Where the application's transport is exported (`export const transport`), from the
21
+ * bounded context's folder. `../common/infrastructure/transport` by default. */
22
+ transport?: string;
23
+ };
24
+ export declare function scaffoldFilesOf(metamodel: Metamodel, options?: ScaffoldOptions): Record<string, string>;
@@ -0,0 +1,123 @@
1
+ import { aliasOf, FRAMEWORK } from "./names.js";
2
+ /** Names the skeleton's `index.ts` cannot give its constant. */
3
+ const TAKEN = new Set([
4
+ "transport",
5
+ "features",
6
+ "applicationServices",
7
+ ..."break case catch class const continue debugger default delete do else enum export extends false finally for function if import in instanceof new null return super switch this throw true try typeof var void while with yield let static implements interface package private protected public await".split(" "),
8
+ ]);
9
+ /** `sales-orders` → `SalesOrders`. */
10
+ function pascalOf(alias) {
11
+ const joined = alias
12
+ .split(/[^A-Za-z0-9]+/)
13
+ .filter((one) => one !== "")
14
+ .map((one) => `${one.charAt(0).toUpperCase()}${one.slice(1)}`)
15
+ .join("");
16
+ return joined === "" ? "Context" : /^[0-9]/.test(joined) ? `_${joined}` : joined;
17
+ }
18
+ /** A module the skeleton imports, built in parts — see `FRAMEWORK` (`codegen/names.ts`). */
19
+ const at = (...parts) => parts.join("/");
20
+ const HEADER = (what) => `// ${what} — written once by \`sincpro-client scaffold\`; the application's to change.`;
21
+ export function scaffoldFilesOf(metamodel, options = {}) {
22
+ const alias = aliasOf(metamodel);
23
+ const Name = pascalOf(alias);
24
+ const bare = `${Name.charAt(0).toLowerCase()}${Name.slice(1)}`;
25
+ // A word the language keeps (`import`, `default`) or a name `index.ts` already imports cannot
26
+ // be the constant's: it takes the bounded context's word instead.
27
+ const name = TAKEN.has(bare) ? `${bare}BoundedContext` : bare;
28
+ const Dependencies = `${Name}Dependencies`;
29
+ const client = metamodel.bindings !== undefined;
30
+ const transport = options.transport ?? at("..", "common", "infrastructure", "transport");
31
+ const dependencies = [
32
+ HEADER(`What the services of «${alias}» can count on`),
33
+ "",
34
+ `import type { Transport, UseBoundedContext } from "${FRAMEWORK}";`,
35
+ ...(client ? ["", `import { clientOf } from "${at("..", "generated", "client")}";`] : []),
36
+ "",
37
+ `/** What every service of «${alias}» finds on \`this\`${client ? ": `this.client`, the service's operations" : ""}. */`,
38
+ `export interface ${Dependencies} {`,
39
+ ...(client
40
+ ? [" client: ReturnType<typeof clientOf>;"]
41
+ : [
42
+ " /** What the services need: a repository, an adapter, another bounded context. */",
43
+ ]),
44
+ "}",
45
+ "",
46
+ "export function registerDependencies(",
47
+ ` bc: UseBoundedContext<${Dependencies}>,`,
48
+ ` ${client ? "transport" : "_transport"}: Transport,`,
49
+ ") {",
50
+ ...(client ? [` bc.addDependency("client", clientOf(transport));`] : []),
51
+ " return bc;",
52
+ "}",
53
+ "",
54
+ ].join("\n");
55
+ const boundedContext = [
56
+ HEADER(`How «${alias}» is made`),
57
+ "",
58
+ "import {",
59
+ " ApplicationService as BaseApplicationService,",
60
+ " Feature as BaseFeature,",
61
+ " type Transport,",
62
+ " UseBoundedContext,",
63
+ `} from "${FRAMEWORK}";`,
64
+ "",
65
+ `import type { ${Name}Features } from "${at("..", "services")}";`,
66
+ `import { type ${Dependencies}, registerDependencies } from "${at(".", "dependencies")}";`,
67
+ "",
68
+ `/** A Feature of «${alias}»: its dependencies typed on \`this\`, and \`this.context\`. */`,
69
+ `export class Feature extends BaseFeature<${Dependencies}> {}`,
70
+ "",
71
+ `/** An ApplicationService of «${alias}»: the same, and \`this.feature.<Name>\` for each Feature. */`,
72
+ "export class ApplicationService extends BaseApplicationService<",
73
+ ` ${Dependencies},`,
74
+ ` ${Name}Features`,
75
+ "> {}",
76
+ "",
77
+ `export function config${Name}BoundedContext(name: string, transport: Transport) {`,
78
+ ` return registerDependencies(new UseBoundedContext<${Dependencies}>(name), transport);`,
79
+ "}",
80
+ "",
81
+ ].join("\n");
82
+ const services = [
83
+ HEADER(`What «${alias}» offers`),
84
+ "",
85
+ "/**",
86
+ ` * Each service by the name it is reached by — \`${name}.Issues.close(…)\`. A Feature groups the`,
87
+ " * operations of one part of the domain; an ApplicationService composes the Features:",
88
+ " *",
89
+ " * // services/issues.ts",
90
+ ` * export class Issues extends Feature {`,
91
+ " * close(input: dto.CommandCloseIssue) {",
92
+ " * return this.client.closeIssue(input);",
93
+ " * }",
94
+ " * }",
95
+ " */",
96
+ "export const features = {};",
97
+ "export const applicationServices = {};",
98
+ "",
99
+ `export type ${Name}Features = typeof features;`,
100
+ "",
101
+ ].join("\n");
102
+ const index = [
103
+ HEADER(`«${alias}», ready to use`),
104
+ "",
105
+ `import { transport } from "${transport}";`,
106
+ "",
107
+ `import { config${Name}BoundedContext } from "${at(".", "infrastructure", "bounded_context")}";`,
108
+ `import { applicationServices, features } from "${at(".", "services")}";`,
109
+ "",
110
+ `/** «${alias}»: \`${name}.<Service>.<method>(…)\` — from a screen, a test, another bounded context. */`,
111
+ `export const ${name} = config${Name}BoundedContext(${JSON.stringify(alias)}, transport).register({`,
112
+ " features,",
113
+ " applicationServices,",
114
+ "});",
115
+ "",
116
+ ].join("\n");
117
+ return {
118
+ "infrastructure/dependencies.ts": dependencies,
119
+ "infrastructure/bounded_context.ts": boundedContext,
120
+ "services/index.ts": services,
121
+ "index.ts": index,
122
+ };
123
+ }
@@ -0,0 +1,110 @@
1
+ import type { AnyRecord, Count, Criteria } from "@sincpro/criteria";
2
+ /**
3
+ * A to-many as a reading answers it — sincpro_framework's `EntityCollection`: the records, how
4
+ * many there are, and the cursor to go on inside this parent. Never a bare list, which would
5
+ * say the page is all there is.
6
+ *
7
+ * customer.invoices -> { items: [i1, …, i40], count: { value: 1000, exact: false }, cursor: "eyJ…" }
8
+ *
9
+ * Context: a customer may have a million invoices, and the read brings forty of them with
10
+ * «at least a thousand» — the backend counts up to a cap (`RELATION_COUNT_CAP`) because an exact
11
+ * total reads every child. A screen that counted `items` showed forty and called it the total;
12
+ * one that iterated an array never knew there was more. The same type on every platform, so the
13
+ * web, the phone and a script read a relation the same way.
14
+ *
15
+ * `count` is `null` when the read asked for none (`CountMode.NONE`): the cursor alone says there
16
+ * is more. A collection is written back only whole — the backend refuses a page of it, because
17
+ * written back it would remove what it did not bring.
18
+ */
19
+ export type EntityCollection<T> = {
20
+ readonly items: readonly T[];
21
+ readonly count?: Count | null;
22
+ readonly cursor?: string | null;
23
+ };
24
+ /**
25
+ * A collection with every part said — what `collectionOf` answers —, edited the way a string or
26
+ * a date is: `append`, `extend`, `remove` and `replace` answer a new collection and never change
27
+ * this one, as sincpro_framework's `EntityCollection` does.
28
+ *
29
+ * const lines = collectionOf(invoice.lines);
30
+ * const next = lines.append(newLine).remove(oldLine.id); // lines is as it was
31
+ *
32
+ * An edit keeps what the collection says: a page stays a page (its cursor; a floor stays a
33
+ * floor), an exact count moves with what was added or removed. `remove` and `replace` of a record
34
+ * it does not hold throw, as Python's `list.remove` does. Written as JSON it is the wire's shape:
35
+ * `{ items, count, cursor }`.
36
+ */
37
+ export declare class HeldCollection<T> implements EntityCollection<T> {
38
+ #private;
39
+ readonly items: readonly T[];
40
+ readonly count: Count | null;
41
+ readonly cursor: string | null;
42
+ constructor(items: readonly T[], count?: Count | null, cursor?: string | null, identity?: string);
43
+ /** How many records are held — this page, never the total (`count` is that). */
44
+ get length(): number;
45
+ /** Whether it holds less than there is (`isPartial`). */
46
+ get partial(): boolean;
47
+ [Symbol.iterator](): Iterator<T>;
48
+ /** A new collection with `records` at the end. */
49
+ append(...records: T[]): HeldCollection<T>;
50
+ /** A new collection with every one of `records` at the end, in order. */
51
+ extend(records: Iterable<T>): HeldCollection<T>;
52
+ /** A new collection without these records — each named by itself or by its identity. */
53
+ remove(...records: unknown[]): HeldCollection<T>;
54
+ /** A new collection where each of `records` takes the place of the held one with its identity. */
55
+ replace(...records: T[]): HeldCollection<T>;
56
+ toJSON(): EntityCollection<T>;
57
+ }
58
+ /** Whether `value` is a collection as a reading answers one (`{items, …}`). */
59
+ export declare function isCollection(value: unknown): value is EntityCollection<unknown>;
60
+ /**
61
+ * Whatever a relation arrived as, as a collection with every part said.
62
+ *
63
+ * collectionOf({ items: [a, b], count: { value: 30, exact: true }, cursor: "x" })
64
+ * -> the same, `cursor` and `count` said
65
+ * collectionOf([a, b]) -> { items: [a, b], count: { value: 2, exact: true }, cursor: null }
66
+ * collectionOf(undefined) -> { items: [], count: { value: 0, exact: true }, cursor: null }
67
+ *
68
+ * An array — a domain written by hand, a record built in memory — holds all there is. `identity`
69
+ * is the field `remove` and `replace` match records by (the model's `identity`, `"id"`).
70
+ */
71
+ export declare function collectionOf<T>(value: EntityCollection<T> | readonly T[] | null | undefined, identity?: string): HeldCollection<T>;
72
+ /**
73
+ * Whether the collection holds less than there is — sincpro_framework's `is_partial`, the same
74
+ * rule: a cursor says there is more; no count claims nothing; a floor, or a count above what it
75
+ * holds, says there is more.
76
+ *
77
+ * isPartial({ items: [a], cursor: "x" }) -> true
78
+ * isPartial({ items: [a], count: { value: 1000, exact: false } }) -> true
79
+ * isPartial({ items: [a, b], count: { value: 2, exact: true } }) -> false
80
+ * isPartial({ items: [a] }) -> false
81
+ */
82
+ export declare function isPartial(collection: EntityCollection<unknown>): boolean;
83
+ /**
84
+ * How many there are, as well as the collection knows: its count, or — when the read asked for
85
+ * none — what it holds, exact only when nothing says there is more.
86
+ *
87
+ * totalOf({ items: [a, b], count: { value: 1000, exact: false } }) -> { value: 1000, exact: false }
88
+ * totalOf({ items: [a, b], cursor: "x" }) -> { value: 2, exact: false }
89
+ */
90
+ export declare function totalOf(collection: EntityCollection<unknown>): Count;
91
+ /** How a relation ties two records, as a model's definition says it (`FieldMeta`). */
92
+ export type RelationKeys = {
93
+ identified_by?: string | null;
94
+ parent_field?: string | null;
95
+ related_field?: string | null;
96
+ };
97
+ /**
98
+ * The question «the related records of this one», over the related entity — sincpro_framework's
99
+ * `References.criteria()`: what pages a relation past what its record brought, searches it and
100
+ * orders it, a list like any other. `criteria` narrows it; it never widens it.
101
+ *
102
+ * relatedCriteria(meta.fields.invoices, customer, "id")
103
+ * -> { selection: { field: "customer_id", operator: "=", value: customer.id } }
104
+ * relatedCriteria(field, customer, "id", { order: [{ field: "issued_on", descending: true }] })
105
+ *
106
+ * The two fields the definition names (`parent_field`, `related_field`) are read when it names
107
+ * them — a relation by a business key —; `identified_by` against the record's own key otherwise.
108
+ * A relation whose definition says neither cannot be asked, and says so.
109
+ */
110
+ export declare function relatedCriteria<T extends AnyRecord>(keys: RelationKeys, record: AnyRecord, identity?: string, criteria?: Criteria<T>): Criteria<T>;
@@ -0,0 +1,169 @@
1
+ /**
2
+ * A collection with every part said — what `collectionOf` answers —, edited the way a string or
3
+ * a date is: `append`, `extend`, `remove` and `replace` answer a new collection and never change
4
+ * this one, as sincpro_framework's `EntityCollection` does.
5
+ *
6
+ * const lines = collectionOf(invoice.lines);
7
+ * const next = lines.append(newLine).remove(oldLine.id); // lines is as it was
8
+ *
9
+ * An edit keeps what the collection says: a page stays a page (its cursor; a floor stays a
10
+ * floor), an exact count moves with what was added or removed. `remove` and `replace` of a record
11
+ * it does not hold throw, as Python's `list.remove` does. Written as JSON it is the wire's shape:
12
+ * `{ items, count, cursor }`.
13
+ */
14
+ export class HeldCollection {
15
+ items;
16
+ count;
17
+ cursor;
18
+ /** The field each record's identity is in — what `remove` and `replace` match by. */
19
+ #identity;
20
+ constructor(items, count = null, cursor = null, identity = "id") {
21
+ this.items = Object.freeze([...items]);
22
+ this.count = count;
23
+ this.cursor = cursor;
24
+ this.#identity = identity;
25
+ Object.freeze(this);
26
+ }
27
+ /** How many records are held — this page, never the total (`count` is that). */
28
+ get length() {
29
+ return this.items.length;
30
+ }
31
+ /** Whether it holds less than there is (`isPartial`). */
32
+ get partial() {
33
+ return isPartial(this);
34
+ }
35
+ [Symbol.iterator]() {
36
+ return this.items[Symbol.iterator]();
37
+ }
38
+ /** A new collection with `records` at the end. */
39
+ append(...records) {
40
+ return this.#edited([...this.items, ...records], records.length);
41
+ }
42
+ /** A new collection with every one of `records` at the end, in order. */
43
+ extend(records) {
44
+ return this.append(...records);
45
+ }
46
+ /** A new collection without these records — each named by itself or by its identity. */
47
+ remove(...records) {
48
+ const unwanted = new Set(records.map((one) => this.#keyOf(one)));
49
+ const held = new Set(this.items.map((one) => this.#keyOf(one)));
50
+ const missing = [...unwanted].filter((key) => !held.has(key));
51
+ if (missing.length > 0)
52
+ throw new Error(`not in the collection: ${missing.join(", ")}`);
53
+ return this.#edited(this.items.filter((one) => !unwanted.has(this.#keyOf(one))), -unwanted.size);
54
+ }
55
+ /** A new collection where each of `records` takes the place of the held one with its identity. */
56
+ replace(...records) {
57
+ const byKey = new Map(records.map((one) => [this.#keyOf(one), one]));
58
+ const held = new Set(this.items.map((one) => this.#keyOf(one)));
59
+ const missing = [...byKey.keys()].filter((key) => !held.has(key));
60
+ if (missing.length > 0)
61
+ throw new Error(`not in the collection: ${missing.join(", ")} — append adds a record`);
62
+ return this.#edited(this.items.map((one) => byKey.get(this.#keyOf(one)) ?? one), 0);
63
+ }
64
+ toJSON() {
65
+ return { items: this.items, count: this.count, cursor: this.cursor };
66
+ }
67
+ #keyOf(record) {
68
+ if (typeof record !== "object" || record === null)
69
+ return record;
70
+ const key = record[this.#identity];
71
+ // A record not stored yet has no identity: it is itself, never every other new one too.
72
+ return key === undefined || key === null || key === "" ? record : key;
73
+ }
74
+ #edited(items, moved) {
75
+ const count = this.count !== null && this.count.exact
76
+ ? { value: Math.max(this.count.value + moved, 0), exact: true }
77
+ : this.count;
78
+ return new HeldCollection(items, count, this.cursor, this.#identity);
79
+ }
80
+ }
81
+ /** Whether `value` is a collection as a reading answers one (`{items, …}`). */
82
+ export function isCollection(value) {
83
+ if (typeof value !== "object" || value === null || Array.isArray(value))
84
+ return false;
85
+ if (!("items" in value))
86
+ return false;
87
+ const items = value.items;
88
+ // A page whose records did not come (`items: null`) is still a page: its count says so.
89
+ return Array.isArray(items) || items === null || items === undefined;
90
+ }
91
+ /**
92
+ * Whatever a relation arrived as, as a collection with every part said.
93
+ *
94
+ * collectionOf({ items: [a, b], count: { value: 30, exact: true }, cursor: "x" })
95
+ * -> the same, `cursor` and `count` said
96
+ * collectionOf([a, b]) -> { items: [a, b], count: { value: 2, exact: true }, cursor: null }
97
+ * collectionOf(undefined) -> { items: [], count: { value: 0, exact: true }, cursor: null }
98
+ *
99
+ * An array — a domain written by hand, a record built in memory — holds all there is. `identity`
100
+ * is the field `remove` and `replace` match records by (the model's `identity`, `"id"`).
101
+ */
102
+ export function collectionOf(value, identity = "id") {
103
+ if (value instanceof HeldCollection && identity === "id")
104
+ return value;
105
+ if (Array.isArray(value))
106
+ return new HeldCollection(value, { value: value.length, exact: true }, null, identity);
107
+ if (isCollection(value)) {
108
+ const page = value;
109
+ return new HeldCollection(page.items ?? [], page.count ?? null, page.cursor ?? null, identity);
110
+ }
111
+ return new HeldCollection([], { value: 0, exact: true }, null, identity);
112
+ }
113
+ /**
114
+ * Whether the collection holds less than there is — sincpro_framework's `is_partial`, the same
115
+ * rule: a cursor says there is more; no count claims nothing; a floor, or a count above what it
116
+ * holds, says there is more.
117
+ *
118
+ * isPartial({ items: [a], cursor: "x" }) -> true
119
+ * isPartial({ items: [a], count: { value: 1000, exact: false } }) -> true
120
+ * isPartial({ items: [a, b], count: { value: 2, exact: true } }) -> false
121
+ * isPartial({ items: [a] }) -> false
122
+ */
123
+ export function isPartial(collection) {
124
+ if (collection.cursor != null)
125
+ return true;
126
+ const count = collection.count;
127
+ if (count == null)
128
+ return false;
129
+ return !count.exact || count.value > (collection.items ?? []).length;
130
+ }
131
+ /**
132
+ * How many there are, as well as the collection knows: its count, or — when the read asked for
133
+ * none — what it holds, exact only when nothing says there is more.
134
+ *
135
+ * totalOf({ items: [a, b], count: { value: 1000, exact: false } }) -> { value: 1000, exact: false }
136
+ * totalOf({ items: [a, b], cursor: "x" }) -> { value: 2, exact: false }
137
+ */
138
+ export function totalOf(collection) {
139
+ return (collection.count ?? {
140
+ value: (collection.items ?? []).length,
141
+ exact: collection.cursor == null,
142
+ });
143
+ }
144
+ /**
145
+ * The question «the related records of this one», over the related entity — sincpro_framework's
146
+ * `References.criteria()`: what pages a relation past what its record brought, searches it and
147
+ * orders it, a list like any other. `criteria` narrows it; it never widens it.
148
+ *
149
+ * relatedCriteria(meta.fields.invoices, customer, "id")
150
+ * -> { selection: { field: "customer_id", operator: "=", value: customer.id } }
151
+ * relatedCriteria(field, customer, "id", { order: [{ field: "issued_on", descending: true }] })
152
+ *
153
+ * The two fields the definition names (`parent_field`, `related_field`) are read when it names
154
+ * them — a relation by a business key —; `identified_by` against the record's own key otherwise.
155
+ * A relation whose definition says neither cannot be asked, and says so.
156
+ */
157
+ export function relatedCriteria(keys, record, identity = "id", criteria = {}) {
158
+ const there = keys.related_field ?? keys.identified_by;
159
+ const here = keys.related_field != null ? (keys.parent_field ?? identity) : identity;
160
+ if (there == null || there === "")
161
+ throw new Error("this relation's definition names no key to ask it by (identified_by, related_field)");
162
+ const mine = { field: there, operator: "=", value: record[here] };
163
+ return {
164
+ ...criteria,
165
+ selection: (criteria.selection === undefined
166
+ ? mine
167
+ : { all: [criteria.selection, mine] }),
168
+ };
169
+ }
@@ -19,6 +19,12 @@ const TEN = 10n;
19
19
  function pow10(exponent) {
20
20
  return TEN ** BigInt(exponent);
21
21
  }
22
+ /** How many digits after the point a result keeps: a whole number from 0 up. */
23
+ function decimalsGiven(decimals) {
24
+ if (!Number.isInteger(decimals) || decimals < 0)
25
+ throw new RangeError(`${decimals} decimals: a decimal keeps 0 or more whole digits after the point`);
26
+ return decimals;
27
+ }
22
28
  /** `units / divisor` rounded half up, away from zero. */
23
29
  function halfUp(units, divisor) {
24
30
  const quotient = units / divisor;
@@ -97,6 +103,7 @@ export class Decimal {
97
103
  }
98
104
  /** This over `divisor`, with `decimals` digits after the point, rounded half up. */
99
105
  dividedBy(divisor, decimals) {
106
+ decimalsGiven(decimals);
100
107
  const by = Decimal.of(divisor);
101
108
  if (by.units === 0n)
102
109
  throw new RangeError("a decimal divided by zero");
@@ -106,6 +113,7 @@ export class Decimal {
106
113
  }
107
114
  /** With `decimals` digits after the point: rounded half up when it had more, padded when fewer. */
108
115
  rounded(decimals) {
116
+ decimalsGiven(decimals);
109
117
  if (decimals >= this.scale)
110
118
  return Decimal.at(this.units * pow10(decimals - this.scale), decimals);
111
119
  return Decimal.at(halfUp(this.units, pow10(this.scale - decimals)), decimals);
@@ -39,7 +39,11 @@ export type Rebuild<T> = {
39
39
  * `null` in a list, as JSON does.
40
40
  */
41
41
  export declare function valuesOf(value: unknown): JsonValue;
42
- /** What `valuesOf` wrote, from JSON text or already parsed — each tagged bytes back as bytes. */
42
+ /**
43
+ * What `valuesOf` wrote, from JSON text or already parsed — each tagged bytes back as bytes. A
44
+ * string is JSON text: to read values that were written but never sent, go through `pack`
45
+ * (`readValues(pack(value))`), as the in-memory transport does.
46
+ */
43
47
  export declare function readValues(text: string | unknown): unknown;
44
48
  /** `values` read as `of` — `Money`, a DTO's own reader — or as they are without one. */
45
49
  export declare function rebuilt<T = unknown>(values: unknown, of?: Rebuild<T>): T;
@@ -54,8 +54,9 @@ function bytesOf(text) {
54
54
  return bytes;
55
55
  }
56
56
  function isBytes(value) {
57
- return (value instanceof Uint8Array || value instanceof ArrayBuffer || value instanceof DataView);
57
+ return value instanceof ArrayBuffer || ArrayBuffer.isView(value);
58
58
  }
59
+ /** Any binary — a `Uint8Array`, another typed array, a `DataView`, a buffer — as its bytes. */
59
60
  function asBytes(value) {
60
61
  if (value instanceof Uint8Array)
61
62
  return value;
@@ -85,8 +86,13 @@ function written(value, where, seen) {
85
86
  seen.add(object);
86
87
  try {
87
88
  const own = object.toJSON;
88
- if (typeof own === "function")
89
- return written(own.call(object), where, seen);
89
+ if (typeof own === "function") {
90
+ const said = own.call(object);
91
+ // A `toJSON` that answers the object itself says nothing new: it is written as an object.
92
+ // Still in `seen` while what it said is written: `{ self: this }` is a cycle, not a loop.
93
+ if (said !== object)
94
+ return written(said, where, seen);
95
+ }
90
96
  if (object instanceof Map)
91
97
  return Object.fromEntries([...object].flatMap(([key, one]) => {
92
98
  const done = written(one, `${where}.${String(key)}`, seen);
@@ -96,6 +102,9 @@ function written(value, where, seen) {
96
102
  return [...object].map((one, at) => written(one, `${where}[${at}]`, seen) ?? null);
97
103
  const entries = [];
98
104
  for (const [key, one] of Object.entries(object)) {
105
+ // Its own way to travel is not a value of it.
106
+ if (key === "toJSON" && typeof one === "function")
107
+ continue;
99
108
  const done = written(one, `${where}.${key}`, seen);
100
109
  if (done !== undefined)
101
110
  entries.push([key, done]);
@@ -127,7 +136,11 @@ function walked(value) {
127
136
  return restored(Object.fromEntries(Object.entries(value).map(([key, one]) => [key, walked(one)])));
128
137
  return value;
129
138
  }
130
- /** What `valuesOf` wrote, from JSON text or already parsed — each tagged bytes back as bytes. */
139
+ /**
140
+ * What `valuesOf` wrote, from JSON text or already parsed — each tagged bytes back as bytes. A
141
+ * string is JSON text: to read values that were written but never sent, go through `pack`
142
+ * (`readValues(pack(value))`), as the in-memory transport does.
143
+ */
131
144
  export function readValues(text) {
132
145
  if (typeof text === "string")
133
146
  return JSON.parse(text, (_key, value) => value !== null && typeof value === "object" && !Array.isArray(value)
@@ -33,7 +33,8 @@ export declare function wallTimeOf(instant: string, timeZone: string): string |
33
33
  * first guess, and again at the answer when the guess fell on the other side of a change.
34
34
  */
35
35
  export declare function instantOf(wall: string, timeZone: string): string | null;
36
- /** A day of the calendar as the server writes it: `"2026-03-12"` — never an instant. */
36
+ /** A day of the calendar as the server writes it: `"2026-03-12"` — never an instant, never a day
37
+ * the calendar does not have (`"2026-02-30"`, which `Date` would roll into March). */
37
38
  export declare function isDay(text: string): boolean;
38
39
  /** The day it is at `instant` on the wall clock of `timeZone`: what «today» means for a company.
39
40
  * `null` when it is not an instant. */