@jini-ai/cms 0.5.3 → 0.5.4
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/CHANGELOG.md +4 -0
- package/dist/forms/delete-submission.d.ts +38 -0
- package/dist/forms/delete-submission.js +25 -0
- package/dist/forms/duplicate-slug.d.ts +81 -0
- package/dist/forms/duplicate-slug.js +115 -0
- package/dist/forms/errors.d.ts +53 -0
- package/dist/forms/errors.js +25 -0
- package/dist/forms/express/handler.d.ts +59 -0
- package/dist/forms/express/handler.js +217 -0
- package/dist/forms/express/index.d.ts +2 -0
- package/dist/forms/express/index.js +2 -0
- package/dist/forms/fields-json.d.ts +14 -0
- package/dist/forms/fields-json.js +11 -0
- package/dist/forms/forms.d.ts +59 -0
- package/dist/forms/forms.js +213 -0
- package/dist/forms/html/authoring-adapter.d.ts +34 -0
- package/dist/forms/html/authoring-adapter.js +48 -0
- package/dist/forms/html/html-authoring.d.ts +12 -0
- package/dist/forms/html/html-authoring.js +133 -0
- package/dist/forms/html/html-submission-adapter.d.ts +10 -0
- package/dist/forms/html/html-submission-adapter.js +15 -0
- package/dist/forms/html/index.d.ts +4 -0
- package/dist/forms/html/index.js +4 -0
- package/dist/forms/html-render.d.ts +33 -0
- package/dist/forms/html-render.js +31 -0
- package/dist/forms/index.d.ts +17 -0
- package/dist/forms/index.js +17 -0
- package/dist/forms/notify-subscriber.d.ts +32 -0
- package/dist/forms/notify-subscriber.js +60 -0
- package/dist/forms/ports.d.ts +168 -0
- package/dist/forms/ports.js +2 -0
- package/dist/forms/rate-limit-key.d.ts +8 -0
- package/dist/forms/rate-limit-key.js +7 -0
- package/dist/forms/repo.memory.d.ts +120 -0
- package/dist/forms/repo.memory.js +188 -0
- package/dist/forms/sql/index.d.ts +5 -0
- package/dist/forms/sql/index.js +5 -0
- package/dist/forms/sql/repo.d.ts +94 -0
- package/dist/forms/sql/repo.js +175 -0
- package/dist/forms/sql/repo.rows.d.ts +33 -0
- package/dist/forms/sql/repo.rows.js +64 -0
- package/dist/forms/sql/submission-ip-retention-repo.d.ts +9 -0
- package/dist/forms/sql/submission-ip-retention-repo.js +40 -0
- package/dist/forms/sql/tables.d.ts +32 -0
- package/dist/forms/sql/tables.js +2 -0
- package/dist/forms/submission-attempts.d.ts +70 -0
- package/dist/forms/submission-attempts.js +70 -0
- package/dist/forms/submission-email-validation.d.ts +8 -0
- package/dist/forms/submission-email-validation.js +34 -0
- package/dist/forms/submission-ip-retention.d.ts +30 -0
- package/dist/forms/submission-ip-retention.js +27 -0
- package/dist/forms/submit-service.d.ts +78 -0
- package/dist/forms/submit-service.js +109 -0
- package/dist/forms/types.d.ts +49 -0
- package/dist/forms/types.js +2 -0
- package/dist/forms/write-service.d.ts +83 -0
- package/dist/forms/write-service.js +271 -0
- package/package.json +3 -4
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export * from "./types.js";
|
|
2
|
+
export * from "./ports.js";
|
|
3
|
+
export * from "./errors.js";
|
|
4
|
+
export * from "./forms.js";
|
|
5
|
+
export * from "./rate-limit-key.js";
|
|
6
|
+
export * from "./write-service.js";
|
|
7
|
+
export * from "./submit-service.js";
|
|
8
|
+
export * from "./notify-subscriber.js";
|
|
9
|
+
export * from "./submission-ip-retention.js";
|
|
10
|
+
export * from "./fields-json.js";
|
|
11
|
+
export * from "./html-render.js";
|
|
12
|
+
export * from "./submission-attempts.js";
|
|
13
|
+
export * from "./submission-email-validation.js";
|
|
14
|
+
export * from "./delete-submission.js";
|
|
15
|
+
export * from "./duplicate-slug.js";
|
|
16
|
+
export * from "./repo.memory.js";
|
|
17
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file `registerFormNotifySubscriber` — the Forms-owned outbox subscriber (SPEC-010 REQ-12,
|
|
3
|
+
* ADR-PIPE-010 C-009).
|
|
4
|
+
*
|
|
5
|
+
* Purpose:
|
|
6
|
+
* Subscribes `form.submission.received`; on a notify-enabled definition, calls `MailerPort.send()`
|
|
7
|
+
* per configured recipient. The one piece of Forms-owned business logic riding the outbox — kept
|
|
8
|
+
* separate from the webhook-fanout forwarding (which is not Forms-owned logic; see
|
|
9
|
+
* `server/app.ts`'s wiring). A send failure (thrown or `{ ok: false }`) is logged, never thrown
|
|
10
|
+
* back into `EventBusPort.publish()`'s subscriber loop (`core/events/memory-bus.ts`'s `publish`
|
|
11
|
+
* does not catch handler errors itself — a subscriber that fails to catch its own errors would
|
|
12
|
+
* break every OTHER subscriber to the same event, including the webhook fan-out).
|
|
13
|
+
*
|
|
14
|
+
* Deliberately duplicates the `'form.submission.received'` topic-name literal rather than
|
|
15
|
+
* importing it from `./manifest` — see `types.ts`'s file header for why domain code never reads
|
|
16
|
+
* the manifest back.
|
|
17
|
+
*/
|
|
18
|
+
import type { Logger } from "@jini-ai/core/primitives";
|
|
19
|
+
/** Notification subscriber with host sender, mail and logging ports. Each recipient receives a stable per-submission dedup key; delivery and repository failures never block sibling subscribers. */
|
|
20
|
+
import type { EventBusPort } from "../core/index.js";
|
|
21
|
+
import type { MailerPort, EmailAddress } from "./ports.js";
|
|
22
|
+
import type { FormDefinitionRepoPort, FormSubmissionRepoPort } from "./ports.js";
|
|
23
|
+
export interface RegisterFormNotifySubscriberDeps {
|
|
24
|
+
bus: EventBusPort;
|
|
25
|
+
sender: EmailAddress;
|
|
26
|
+
logger: Pick<Logger, 'warn'>;
|
|
27
|
+
mailer: MailerPort;
|
|
28
|
+
formDefinitionRepo: FormDefinitionRepoPort;
|
|
29
|
+
formSubmissionRepo: FormSubmissionRepoPort;
|
|
30
|
+
}
|
|
31
|
+
export declare function registerFormNotifySubscriber(deps: RegisterFormNotifySubscriberDeps, _optional?: Record<string, never>): Promise<() => Promise<void>>;
|
|
32
|
+
//# sourceMappingURL=notify-subscriber.d.ts.map
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Domain behavior owns its event contract; reading a host manifest back would invert dependency flow.
|
|
2
|
+
const FORM_SUBMISSION_RECEIVED_TOPIC = "form.submission.received";
|
|
3
|
+
export async function registerFormNotifySubscriber(deps, _optional = {}) {
|
|
4
|
+
return deps.bus.subscribe({ eventName: FORM_SUBMISSION_RECEIVED_TOPIC,
|
|
5
|
+
handler: async (event) => {
|
|
6
|
+
try {
|
|
7
|
+
const { workspaceId, formDefinitionId, submissionId } = event.payload;
|
|
8
|
+
const definition = await deps.formDefinitionRepo.findById({ workspaceId, id: formDefinitionId });
|
|
9
|
+
if (!definition || !definition.notify.enabled || definition.notify.recipients.length === 0) {
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
const submission = await deps.formSubmissionRepo.findById({ workspaceId, id: submissionId });
|
|
13
|
+
if (!submission)
|
|
14
|
+
return;
|
|
15
|
+
for (const recipient of definition.notify.recipients) {
|
|
16
|
+
try {
|
|
17
|
+
const result = await deps.mailer.send({
|
|
18
|
+
message: {
|
|
19
|
+
workspaceId,
|
|
20
|
+
to: { email: recipient },
|
|
21
|
+
from: deps.sender,
|
|
22
|
+
subject: `New submission: ${definition.name}`,
|
|
23
|
+
text: JSON.stringify(submission.data),
|
|
24
|
+
},
|
|
25
|
+
options: {
|
|
26
|
+
// Include the recipient so a shared dedup ledger never suppresses a sibling send.
|
|
27
|
+
idempotencyKey: `forms:notify:${submissionId}:${recipient}`,
|
|
28
|
+
workspaceId,
|
|
29
|
+
sourceContext: { module: "forms", ref: formDefinitionId },
|
|
30
|
+
purpose: "transactional",
|
|
31
|
+
// Background notifications use the host mailer's durable-outbox readiness gate,
|
|
32
|
+
// rather than the interactive send lane, when production delivery requires it.
|
|
33
|
+
lane: "notification",
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
if (!result.ok) {
|
|
37
|
+
warn(deps.logger, `[forms:notify] mail send failed for submission '${submissionId}' recipient '${recipient}': ${result.errorCode} (${result.message})`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
// A send failure is terminal for this delivery: log, never retry inline or alter
|
|
42
|
+
// the persisted submission, and continue so the other recipients can still receive it.
|
|
43
|
+
warn(deps.logger, `[forms:notify] mail send threw for submission '${submissionId}' recipient '${recipient}':`, err);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
// Repository failures must not escape the bus's subscriber loop and break sibling handlers.
|
|
49
|
+
warn(deps.logger, "[forms:notify] subscriber failed:", err);
|
|
50
|
+
}
|
|
51
|
+
} });
|
|
52
|
+
}
|
|
53
|
+
/** Failure reporting is isolated too: a logger cannot interrupt sibling event subscribers. */
|
|
54
|
+
function warn(logger, message, error) {
|
|
55
|
+
try {
|
|
56
|
+
logger.warn({ message }, error === undefined ? {} : { error });
|
|
57
|
+
}
|
|
58
|
+
catch { /* Host logging failure is terminal. */ }
|
|
59
|
+
}
|
|
60
|
+
//# sourceMappingURL=notify-subscriber.js.map
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/** Forms persistence and outbound effect contracts. Concrete repositories, HTTP routing, tool wiring and transport implementations belong to consumers. */
|
|
2
|
+
import type { UUID } from "@jini-ai/core/primitives";
|
|
3
|
+
import type { FormDefinitionRecord, FormSubmissionPage, FormSubmissionRecord } from "./types.js";
|
|
4
|
+
/** Definitions and submissions have distinct lifecycles and therefore separate ports.
|
|
5
|
+
* Deletion belongs to the host's Trash/purge workflow, so neither persistence port exposes delete.
|
|
6
|
+
* Reads must hide trashed rows at the repository boundary: public submitters, widget resolvers
|
|
7
|
+
* and other direct consumers must get not-found even if they forget an additional domain filter. */
|
|
8
|
+
export interface FormDefinitionRepoPort {
|
|
9
|
+
/** Reads and updates expose only live records; a trashed row still reserves its slug. */
|
|
10
|
+
findById(required: {
|
|
11
|
+
workspaceId: UUID;
|
|
12
|
+
id: UUID;
|
|
13
|
+
}): Promise<FormDefinitionRecord | null>;
|
|
14
|
+
findBySlug(required: {
|
|
15
|
+
workspaceId: UUID;
|
|
16
|
+
slug: string;
|
|
17
|
+
}): Promise<FormDefinitionRecord | null>;
|
|
18
|
+
list(required: {
|
|
19
|
+
workspaceId: UUID;
|
|
20
|
+
}): Promise<FormDefinitionRecord[]>;
|
|
21
|
+
/** The database unique index is the tie-break for (workspaceId, slug); callers translate collisions.
|
|
22
|
+
* A trashed row retains its slug so restoring it is lossless, and can therefore still conflict. */
|
|
23
|
+
create(record: FormDefinitionRecord): Promise<void>;
|
|
24
|
+
/** Update only a live row, affecting zero rows if it was trashed meanwhile. Only Trash restore
|
|
25
|
+
* may revive it. Keep create/update separate so genuine insert uniqueness races remain visible. */
|
|
26
|
+
update(record: FormDefinitionRecord): Promise<void>;
|
|
27
|
+
/** Deliberately trash-blind: both live and trashed rows reserve a slug. Duplication must not
|
|
28
|
+
* propose a trashed slug as available and then collide with the database unique index. */
|
|
29
|
+
isSlugTaken(required: {
|
|
30
|
+
workspaceId: UUID;
|
|
31
|
+
slug: string;
|
|
32
|
+
}): Promise<boolean>;
|
|
33
|
+
}
|
|
34
|
+
export interface FormSubmissionRepoPort {
|
|
35
|
+
findById(required: {
|
|
36
|
+
workspaceId: UUID;
|
|
37
|
+
id: UUID;
|
|
38
|
+
}): Promise<FormSubmissionRecord | null>;
|
|
39
|
+
create(record: FormSubmissionRecord): Promise<void>;
|
|
40
|
+
/** Optional repository-owned transaction. Hosts may instead bind a wider unit of work. */
|
|
41
|
+
transaction?: SubmissionTransactionPort;
|
|
42
|
+
/** Newest first, submittedAt descending then id descending for ties. The opaque cursor resumes
|
|
43
|
+
* after the last returned row; callers validate limit (1..100) before reaching persistence. */
|
|
44
|
+
listByDefinition(required: {
|
|
45
|
+
workspaceId: UUID;
|
|
46
|
+
formDefinitionId: UUID;
|
|
47
|
+
limit: number;
|
|
48
|
+
}, optional?: {
|
|
49
|
+
cursor?: string | null;
|
|
50
|
+
}): Promise<FormSubmissionPage>;
|
|
51
|
+
}
|
|
52
|
+
export interface RateLimiterPort {
|
|
53
|
+
check(required: {
|
|
54
|
+
key: string;
|
|
55
|
+
}): {
|
|
56
|
+
allowed: true;
|
|
57
|
+
} | {
|
|
58
|
+
allowed: false;
|
|
59
|
+
retryAfterSeconds: number;
|
|
60
|
+
} | Promise<{
|
|
61
|
+
allowed: true;
|
|
62
|
+
} | {
|
|
63
|
+
allowed: false;
|
|
64
|
+
retryAfterSeconds: number;
|
|
65
|
+
}>;
|
|
66
|
+
}
|
|
67
|
+
export interface FormOutboxPort {
|
|
68
|
+
enqueue(event: import("../core/index.js").DomainEvent): Promise<void>;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Runs `work` as one unit of persistence: commit when it resolves, roll back when it throws. A host
|
|
72
|
+
* binds its database transaction here so an accepted submission's row and its outbox event are
|
|
73
|
+
* written together; a submission stored without its event would never notify anyone.
|
|
74
|
+
*/
|
|
75
|
+
export type SubmissionTransactionPort = <T>(work: () => Promise<T>) => Promise<T>;
|
|
76
|
+
/** Bound to the host's outbox/bus/clock; dispatch is kicked off without awaiting delivery. */
|
|
77
|
+
export interface OutboxDispatcherPort {
|
|
78
|
+
dispatch(required: Record<string, never>): Promise<void>;
|
|
79
|
+
}
|
|
80
|
+
export interface EmailAddress {
|
|
81
|
+
email: string;
|
|
82
|
+
name?: string;
|
|
83
|
+
}
|
|
84
|
+
export interface OutboundEmail {
|
|
85
|
+
workspaceId: UUID;
|
|
86
|
+
to: EmailAddress;
|
|
87
|
+
from: EmailAddress;
|
|
88
|
+
subject: string;
|
|
89
|
+
text: string;
|
|
90
|
+
}
|
|
91
|
+
export interface MailerSendOptions {
|
|
92
|
+
idempotencyKey: string;
|
|
93
|
+
workspaceId: UUID;
|
|
94
|
+
sourceContext: {
|
|
95
|
+
module: string;
|
|
96
|
+
ref: string;
|
|
97
|
+
};
|
|
98
|
+
purpose: "transactional";
|
|
99
|
+
lane: "notification";
|
|
100
|
+
}
|
|
101
|
+
export type MailerSendResult = {
|
|
102
|
+
ok: true;
|
|
103
|
+
providerMessageId: string;
|
|
104
|
+
acceptedAt: string;
|
|
105
|
+
} | {
|
|
106
|
+
ok: false;
|
|
107
|
+
retryable: boolean;
|
|
108
|
+
errorCode: string;
|
|
109
|
+
message: string;
|
|
110
|
+
};
|
|
111
|
+
export interface MailerPort {
|
|
112
|
+
send(required: {
|
|
113
|
+
message: OutboundEmail;
|
|
114
|
+
options: MailerSendOptions;
|
|
115
|
+
}): Promise<MailerSendResult>;
|
|
116
|
+
}
|
|
117
|
+
/** Generic audited mutation contract supplied by the host composition root. */
|
|
118
|
+
export type FormCommandExecutorPort = typeof import("../core/index.js").executeCommand;
|
|
119
|
+
/** Authoring stays behind a port so the universal write service never loads an HTML parser. */
|
|
120
|
+
export interface FormAuthoringPort {
|
|
121
|
+
prepare(required: {
|
|
122
|
+
deps: Pick<import("./write-service.js").FormWriteServiceDeps, "authorize">;
|
|
123
|
+
input: import("./fields-json.js").FormAuthoring & {
|
|
124
|
+
workspaceId: string;
|
|
125
|
+
actor: {
|
|
126
|
+
id: string;
|
|
127
|
+
};
|
|
128
|
+
};
|
|
129
|
+
existing?: import("./fields-json.js").HtmlFormDefinitionRecord;
|
|
130
|
+
}, optional?: Record<string, never>): Promise<(import("./fields-json.js").FormAuthoring & {
|
|
131
|
+
fields?: FormDefinitionRecord["fields"];
|
|
132
|
+
}) | undefined>;
|
|
133
|
+
with(required: {
|
|
134
|
+
repo: FormDefinitionRepoPort;
|
|
135
|
+
authoring: import("./fields-json.js").FormAuthoring & {
|
|
136
|
+
fields?: FormDefinitionRecord["fields"];
|
|
137
|
+
};
|
|
138
|
+
}, optional?: {
|
|
139
|
+
replaceFields?: boolean;
|
|
140
|
+
}): FormDefinitionRepoPort;
|
|
141
|
+
}
|
|
142
|
+
/** A submission-only view; never changes the durable definition's descriptors. */
|
|
143
|
+
export type FormDefinitionViewPort = (required: {
|
|
144
|
+
definition: FormDefinitionRecord;
|
|
145
|
+
body: Record<string, unknown>;
|
|
146
|
+
}, optional?: Record<string, never>) => FormDefinitionRecord;
|
|
147
|
+
/** Host-bound Trash removal; only the host's purge lifecycle permanently deletes rows. */
|
|
148
|
+
export type RemoveFormSubmissionFn = (required: {
|
|
149
|
+
workspaceId: UUID;
|
|
150
|
+
id: UUID;
|
|
151
|
+
display: {
|
|
152
|
+
title: string;
|
|
153
|
+
subtitle?: string | null;
|
|
154
|
+
};
|
|
155
|
+
at: string;
|
|
156
|
+
expectedVersion: number | null;
|
|
157
|
+
actor: {
|
|
158
|
+
principalId: string;
|
|
159
|
+
pluginId?: string | null;
|
|
160
|
+
};
|
|
161
|
+
}) => Promise<{
|
|
162
|
+
ok: true;
|
|
163
|
+
version: number | null;
|
|
164
|
+
} | {
|
|
165
|
+
ok: false;
|
|
166
|
+
reason: "not-found" | "version-changed";
|
|
167
|
+
}>;
|
|
168
|
+
//# sourceMappingURL=ports.d.ts.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Builds a per-source, per-form key; rate-limit windows and quotas belong to the host. */
|
|
2
|
+
/** Include the form id so a visitor submitting to two forms from the same IP is never
|
|
3
|
+
* cross-throttled by the other form's submissions. */
|
|
4
|
+
export declare function buildFormsRateLimitKey({ sourceIp, formDefinitionId }: {
|
|
5
|
+
sourceIp: string;
|
|
6
|
+
formDefinitionId: string;
|
|
7
|
+
}): string;
|
|
8
|
+
//# sourceMappingURL=rate-limit-key.d.ts.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Builds a per-source, per-form key; rate-limit windows and quotas belong to the host. */
|
|
2
|
+
/** Include the form id so a visitor submitting to two forms from the same IP is never
|
|
3
|
+
* cross-throttled by the other form's submissions. */
|
|
4
|
+
export function buildFormsRateLimitKey({ sourceIp, formDefinitionId }) {
|
|
5
|
+
return `${sourceIp}:${formDefinitionId}`;
|
|
6
|
+
}
|
|
7
|
+
//# sourceMappingURL=rate-limit-key.js.map
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { UUID } from "@jini-ai/core/primitives";
|
|
2
|
+
import type { FormDefinitionRepoPort, FormSubmissionRepoPort } from "./ports.js";
|
|
3
|
+
import type { FormDefinitionRecord, FormSubmissionPage, FormSubmissionRecord } from "./types.js";
|
|
4
|
+
/**
|
|
5
|
+
* @file In-memory adapters for `forms` (rule-of-two half #1, ADR-006).
|
|
6
|
+
*
|
|
7
|
+
* Purpose:
|
|
8
|
+
* Test/dev-default implementations of `FormDefinitionRepoPort`/`FormSubmissionRepoPort`. Emulates
|
|
9
|
+
* the real DB unique index on `(workspaceId, slug)` (behavior.spec.md §6.1) so `create` throws the
|
|
10
|
+
* same `FormSlugConflictError` shape a `repo.sqlite.ts` unique-constraint violation would map to —
|
|
11
|
+
* write-service.ts's error handling is identical against either adapter.
|
|
12
|
+
*
|
|
13
|
+
* `InMemoryFormDefinitionRepo` stores `deletedAt` internally (mirroring `form_definitions.deleted_at`,
|
|
14
|
+
* owner ruling 2026-09-21) but never returns it through `FormDefinitionRepoPort` — those methods are
|
|
15
|
+
* trash-aware and fail-closed, same as `repo.sqlite.ts`'s `isNull(deletedAt)` reads. `findAnyById`/
|
|
16
|
+
* `save`/`hardDelete` are the deliberate, memory-ONLY exception: a trash-blind seam for the hermetic
|
|
17
|
+
* composition's `createRecordStoreTrashAdapter` (`@jini-ai/cms/trash`), which
|
|
18
|
+
* needs to see and flip a hidden row directly. They are not part of `FormDefinitionRepoPort`.
|
|
19
|
+
*/
|
|
20
|
+
/** Internal storage shape — the one place in this file allowed to know about `deletedAt`. */
|
|
21
|
+
interface StoredFormDefinition extends FormDefinitionRecord {
|
|
22
|
+
deletedAt: string | null;
|
|
23
|
+
}
|
|
24
|
+
export declare class InMemoryFormDefinitionRepo implements FormDefinitionRepoPort {
|
|
25
|
+
private readonly rows;
|
|
26
|
+
/** Trash-aware: never returns a row with `deletedAt` set. */
|
|
27
|
+
findById(required: {
|
|
28
|
+
workspaceId: UUID;
|
|
29
|
+
id: UUID;
|
|
30
|
+
}): Promise<FormDefinitionRecord | null>;
|
|
31
|
+
/** Trash-aware: never returns a row with `deletedAt` set. */
|
|
32
|
+
findBySlug(required: {
|
|
33
|
+
workspaceId: UUID;
|
|
34
|
+
slug: string;
|
|
35
|
+
}): Promise<FormDefinitionRecord | null>;
|
|
36
|
+
/** Trash-aware: never lists a row with `deletedAt` set. */
|
|
37
|
+
list(required: {
|
|
38
|
+
workspaceId: UUID;
|
|
39
|
+
}): Promise<FormDefinitionRecord[]>;
|
|
40
|
+
create(record: FormDefinitionRecord): Promise<void>;
|
|
41
|
+
/**
|
|
42
|
+
* Update-only path for a LIVE row. A stale in-hand copy of a row that has since been trashed is
|
|
43
|
+
* silently a no-op (mirrors `repo.sqlite.ts`'s `isNull(deletedAt)` WHERE guard) — it can never
|
|
44
|
+
* clear a marker it never knew was set. A record with no existing row at all is still accepted
|
|
45
|
+
* (pre-existing behavior of this in-memory double, unrelated to trashing).
|
|
46
|
+
*/
|
|
47
|
+
update(record: FormDefinitionRecord): Promise<void>;
|
|
48
|
+
/** Trash-BLIND — see `ports.ts`'s doc for why this is the one deliberate exception. */
|
|
49
|
+
isSlugTaken(required: {
|
|
50
|
+
workspaceId: UUID;
|
|
51
|
+
slug: string;
|
|
52
|
+
}): Promise<boolean>;
|
|
53
|
+
/**
|
|
54
|
+
* Trash-BLIND — memory-only, NOT part of `FormDefinitionRepoPort`. The hermetic composition's
|
|
55
|
+
* `createRecordStoreTrashAdapter` reads through this to flip `deletedAt` directly, the same way
|
|
56
|
+
* the real adapter reads columns without going through the trash-aware port reads.
|
|
57
|
+
*/
|
|
58
|
+
findAnyById(required: {
|
|
59
|
+
workspaceId: UUID;
|
|
60
|
+
id: UUID;
|
|
61
|
+
}): Promise<StoredFormDefinition | null>;
|
|
62
|
+
/** Trash-BLIND write — memory-only, NOT part of `FormDefinitionRepoPort`. See `findAnyById`'s doc. */
|
|
63
|
+
save(record: StoredFormDefinition): Promise<void>;
|
|
64
|
+
/** Memory-only, NOT part of `FormDefinitionRepoPort`. Removes only the definition row — the
|
|
65
|
+
* composition root pairs this with `InMemoryFormSubmissionRepo.deleteAllForDefinition` the same
|
|
66
|
+
* way `features/trash/adapters/form.ts` deletes submissions before the definition. */
|
|
67
|
+
hardDelete(required: {
|
|
68
|
+
workspaceId: UUID;
|
|
69
|
+
id: UUID;
|
|
70
|
+
}): Promise<void>;
|
|
71
|
+
}
|
|
72
|
+
/** A stored submission: the port's record plus the two Trash columns only the Trash touches. */
|
|
73
|
+
export interface StoredFormSubmission extends FormSubmissionRecord {
|
|
74
|
+
deletedAt: string | null;
|
|
75
|
+
version: number;
|
|
76
|
+
}
|
|
77
|
+
export declare class InMemoryFormSubmissionRepo implements FormSubmissionRepoPort {
|
|
78
|
+
private readonly rows;
|
|
79
|
+
private turn;
|
|
80
|
+
/** Trash-aware, like `repo.sqlite.ts`: a trashed submission reads as missing. */
|
|
81
|
+
findById(required: {
|
|
82
|
+
workspaceId: UUID;
|
|
83
|
+
id: UUID;
|
|
84
|
+
}): Promise<FormSubmissionRecord | null>;
|
|
85
|
+
create(record: FormSubmissionRecord): Promise<void>;
|
|
86
|
+
/** Rolls this repo's rows back when `work` throws, as the SQL adapter's transaction does. Calls
|
|
87
|
+
* take turns (as the SQLite kernel's do) so one rollback cannot erase another call's row; a
|
|
88
|
+
* nested call would wait on itself. Other in-memory stores (the outbox) are not covered.
|
|
89
|
+
* @complexity O(n) snapshot of the stored rows. */
|
|
90
|
+
transaction<T>(work: () => Promise<T>): Promise<T>;
|
|
91
|
+
listByDefinition(required: {
|
|
92
|
+
workspaceId: UUID;
|
|
93
|
+
formDefinitionId: UUID;
|
|
94
|
+
limit: number;
|
|
95
|
+
}, optional?: {
|
|
96
|
+
cursor?: string | null;
|
|
97
|
+
}): Promise<FormSubmissionPage>;
|
|
98
|
+
/** Trash-BLIND — memory-only, NOT part of `FormSubmissionRepoPort`. The hermetic composition's
|
|
99
|
+
* record-store `TrashAdapter` reads and writes the marker through these three, the way the real
|
|
100
|
+
* adapter reads columns without going through the trash-aware port reads. */
|
|
101
|
+
findAnyById(required: {
|
|
102
|
+
workspaceId: UUID;
|
|
103
|
+
id: UUID;
|
|
104
|
+
}): Promise<StoredFormSubmission | null>;
|
|
105
|
+
/** Trash-BLIND write — see `findAnyById`. */
|
|
106
|
+
save(record: StoredFormSubmission): Promise<void>;
|
|
107
|
+
/** Trash-BLIND purge — see `findAnyById`. */
|
|
108
|
+
hardDelete(required: {
|
|
109
|
+
workspaceId: UUID;
|
|
110
|
+
id: UUID;
|
|
111
|
+
}): Promise<void>;
|
|
112
|
+
/** Memory-only, NOT part of `FormSubmissionRepoPort`. Bulk cascade for a form purge — see
|
|
113
|
+
* `InMemoryFormDefinitionRepo.hardDelete`'s doc for why the composition root pairs the two. */
|
|
114
|
+
deleteAllForDefinition(required: {
|
|
115
|
+
workspaceId: UUID;
|
|
116
|
+
formDefinitionId: UUID;
|
|
117
|
+
}): Promise<void>;
|
|
118
|
+
}
|
|
119
|
+
export {};
|
|
120
|
+
//# sourceMappingURL=repo.memory.d.ts.map
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// The local trash fork was removed; @jini-ai/cms/trash owns the shared adapter/hooks.
|
|
2
|
+
import { encodeKeysetCursor } from "../core/keyset-cursor.js";
|
|
3
|
+
import { FormSlugConflictError } from "./errors.js";
|
|
4
|
+
function cloneStored(row) {
|
|
5
|
+
return {
|
|
6
|
+
...row,
|
|
7
|
+
fields: row.fields.map((f) => ({ ...f })),
|
|
8
|
+
notify: { ...row.notify, recipients: [...row.notify.recipients] },
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
/** Strips the internal-only `deletedAt` before a row crosses `FormDefinitionRepoPort`'s boundary. */
|
|
12
|
+
function toPublicRecord(row) {
|
|
13
|
+
const { deletedAt: _deletedAt, ...record } = cloneStored(row);
|
|
14
|
+
return record;
|
|
15
|
+
}
|
|
16
|
+
export class InMemoryFormDefinitionRepo {
|
|
17
|
+
rows = new Map();
|
|
18
|
+
/** Trash-aware: never returns a row with `deletedAt` set. */
|
|
19
|
+
async findById(required) {
|
|
20
|
+
const row = this.rows.get(required.id);
|
|
21
|
+
if (!row || row.workspaceId !== required.workspaceId || row.deletedAt !== null)
|
|
22
|
+
return null;
|
|
23
|
+
return toPublicRecord(row);
|
|
24
|
+
}
|
|
25
|
+
/** Trash-aware: never returns a row with `deletedAt` set. */
|
|
26
|
+
async findBySlug(required) {
|
|
27
|
+
for (const row of this.rows.values()) {
|
|
28
|
+
if (row.workspaceId === required.workspaceId && row.slug === required.slug && row.deletedAt === null) {
|
|
29
|
+
return toPublicRecord(row);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
/** Trash-aware: never lists a row with `deletedAt` set. */
|
|
35
|
+
async list(required) {
|
|
36
|
+
return [...this.rows.values()]
|
|
37
|
+
.filter((row) => row.workspaceId === required.workspaceId && row.deletedAt === null)
|
|
38
|
+
.map(toPublicRecord);
|
|
39
|
+
}
|
|
40
|
+
async create(record) {
|
|
41
|
+
const conflicting = [...this.rows.values()].find((row) => row.workspaceId === record.workspaceId && row.slug === record.slug);
|
|
42
|
+
if (conflicting) {
|
|
43
|
+
// Trash-blind, deliberately — see `ports.ts`'s doc on `create` for why a trashed row still
|
|
44
|
+
// conflicts, and `repo.sqlite.ts`'s identical message for why the two adapters must agree.
|
|
45
|
+
const message = conflicting.deletedAt !== null
|
|
46
|
+
? `a form with slug '${record.slug}' is in the Trash — restore it, or delete it permanently from the Trash, to reuse the slug`
|
|
47
|
+
: `a form with slug '${record.slug}' already exists`;
|
|
48
|
+
throw new FormSlugConflictError({ message, slug: record.slug });
|
|
49
|
+
}
|
|
50
|
+
this.rows.set(record.id, { ...cloneStored({ ...record, deletedAt: null }) });
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Update-only path for a LIVE row. A stale in-hand copy of a row that has since been trashed is
|
|
54
|
+
* silently a no-op (mirrors `repo.sqlite.ts`'s `isNull(deletedAt)` WHERE guard) — it can never
|
|
55
|
+
* clear a marker it never knew was set. A record with no existing row at all is still accepted
|
|
56
|
+
* (pre-existing behavior of this in-memory double, unrelated to trashing).
|
|
57
|
+
*/
|
|
58
|
+
async update(record) {
|
|
59
|
+
const existing = this.rows.get(record.id);
|
|
60
|
+
if (existing && existing.deletedAt !== null)
|
|
61
|
+
return;
|
|
62
|
+
this.rows.set(record.id, cloneStored({ ...record, deletedAt: existing?.deletedAt ?? null }));
|
|
63
|
+
}
|
|
64
|
+
/** Trash-BLIND — see `ports.ts`'s doc for why this is the one deliberate exception. */
|
|
65
|
+
async isSlugTaken(required) {
|
|
66
|
+
for (const row of this.rows.values()) {
|
|
67
|
+
if (row.workspaceId === required.workspaceId && row.slug === required.slug)
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Trash-BLIND — memory-only, NOT part of `FormDefinitionRepoPort`. The hermetic composition's
|
|
74
|
+
* `createRecordStoreTrashAdapter` reads through this to flip `deletedAt` directly, the same way
|
|
75
|
+
* the real adapter reads columns without going through the trash-aware port reads.
|
|
76
|
+
*/
|
|
77
|
+
async findAnyById(required) {
|
|
78
|
+
const row = this.rows.get(required.id);
|
|
79
|
+
if (!row || row.workspaceId !== required.workspaceId)
|
|
80
|
+
return null;
|
|
81
|
+
return cloneStored(row);
|
|
82
|
+
}
|
|
83
|
+
/** Trash-BLIND write — memory-only, NOT part of `FormDefinitionRepoPort`. See `findAnyById`'s doc. */
|
|
84
|
+
async save(record) {
|
|
85
|
+
this.rows.set(record.id, cloneStored(record));
|
|
86
|
+
}
|
|
87
|
+
/** Memory-only, NOT part of `FormDefinitionRepoPort`. Removes only the definition row — the
|
|
88
|
+
* composition root pairs this with `InMemoryFormSubmissionRepo.deleteAllForDefinition` the same
|
|
89
|
+
* way `features/trash/adapters/form.ts` deletes submissions before the definition. */
|
|
90
|
+
async hardDelete(required) {
|
|
91
|
+
const row = this.rows.get(required.id);
|
|
92
|
+
if (row && row.workspaceId === required.workspaceId) {
|
|
93
|
+
this.rows.delete(required.id);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function cloneSubmission(row) {
|
|
98
|
+
return { ...row, data: { ...row.data } };
|
|
99
|
+
}
|
|
100
|
+
/** The port's view of a stored row: the Trash columns stay inside this adapter. */
|
|
101
|
+
function toSubmissionRecord(row) {
|
|
102
|
+
const { deletedAt: _deletedAt, version: _version, ...record } = row;
|
|
103
|
+
return { ...record, data: { ...record.data } };
|
|
104
|
+
}
|
|
105
|
+
export class InMemoryFormSubmissionRepo {
|
|
106
|
+
rows = new Map();
|
|
107
|
+
turn = Promise.resolve();
|
|
108
|
+
/** Trash-aware, like `repo.sqlite.ts`: a trashed submission reads as missing. */
|
|
109
|
+
async findById(required) {
|
|
110
|
+
const row = this.rows.get(required.id);
|
|
111
|
+
if (!row || row.workspaceId !== required.workspaceId || row.deletedAt !== null)
|
|
112
|
+
return null;
|
|
113
|
+
return toSubmissionRecord(row);
|
|
114
|
+
}
|
|
115
|
+
async create(record) {
|
|
116
|
+
this.rows.set(record.id, { ...record, data: { ...record.data }, deletedAt: null, version: 1 });
|
|
117
|
+
}
|
|
118
|
+
/** Rolls this repo's rows back when `work` throws, as the SQL adapter's transaction does. Calls
|
|
119
|
+
* take turns (as the SQLite kernel's do) so one rollback cannot erase another call's row; a
|
|
120
|
+
* nested call would wait on itself. Other in-memory stores (the outbox) are not covered.
|
|
121
|
+
* @complexity O(n) snapshot of the stored rows. */
|
|
122
|
+
transaction(work) {
|
|
123
|
+
const run = this.turn.then(async () => {
|
|
124
|
+
const snapshot = new Map(this.rows);
|
|
125
|
+
try {
|
|
126
|
+
return await work();
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
this.rows.clear();
|
|
130
|
+
for (const [id, row] of snapshot)
|
|
131
|
+
this.rows.set(id, row);
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
this.turn = run.catch(() => undefined);
|
|
136
|
+
return run;
|
|
137
|
+
}
|
|
138
|
+
async listByDefinition(required, optional = {}) {
|
|
139
|
+
const all = [...this.rows.values()]
|
|
140
|
+
.filter((row) => row.workspaceId === required.workspaceId &&
|
|
141
|
+
row.formDefinitionId === required.formDefinitionId &&
|
|
142
|
+
row.deletedAt === null)
|
|
143
|
+
// Newest-first (behavior.spec.md §2.1): submittedAt desc, id desc tie-break.
|
|
144
|
+
.sort((a, b) => {
|
|
145
|
+
if (a.submittedAt !== b.submittedAt)
|
|
146
|
+
return b.submittedAt.localeCompare(a.submittedAt);
|
|
147
|
+
return b.id.localeCompare(a.id);
|
|
148
|
+
});
|
|
149
|
+
// Keep the published id cursor (unknown ids restart at the first page). Internally the
|
|
150
|
+
// shared keyset owner identifies a position by its timestamp and id, without another codec.
|
|
151
|
+
const cursorRow = optional.cursor ? all.find((row) => row.id === optional.cursor) : undefined;
|
|
152
|
+
const position = cursorRow ? encodeKeysetCursor({ createdAt: cursorRow.submittedAt, id: cursorRow.id }) : undefined;
|
|
153
|
+
const startIndex = position
|
|
154
|
+
? all.findIndex((row) => encodeKeysetCursor({ createdAt: row.submittedAt, id: row.id }) === position) + 1
|
|
155
|
+
: 0;
|
|
156
|
+
const page = all.slice(startIndex, startIndex + required.limit);
|
|
157
|
+
const nextCursor = startIndex + required.limit < all.length ? page[page.length - 1]?.id ?? null : null;
|
|
158
|
+
return { items: page.map(toSubmissionRecord), nextCursor };
|
|
159
|
+
}
|
|
160
|
+
/** Trash-BLIND — memory-only, NOT part of `FormSubmissionRepoPort`. The hermetic composition's
|
|
161
|
+
* record-store `TrashAdapter` reads and writes the marker through these three, the way the real
|
|
162
|
+
* adapter reads columns without going through the trash-aware port reads. */
|
|
163
|
+
async findAnyById(required) {
|
|
164
|
+
const row = this.rows.get(required.id);
|
|
165
|
+
return row && row.workspaceId === required.workspaceId ? cloneSubmission(row) : null;
|
|
166
|
+
}
|
|
167
|
+
/** Trash-BLIND write — see `findAnyById`. */
|
|
168
|
+
async save(record) {
|
|
169
|
+
this.rows.set(record.id, cloneSubmission(record));
|
|
170
|
+
}
|
|
171
|
+
/** Trash-BLIND purge — see `findAnyById`. */
|
|
172
|
+
async hardDelete(required) {
|
|
173
|
+
const row = this.rows.get(required.id);
|
|
174
|
+
if (row && row.workspaceId === required.workspaceId) {
|
|
175
|
+
this.rows.delete(required.id);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/** Memory-only, NOT part of `FormSubmissionRepoPort`. Bulk cascade for a form purge — see
|
|
179
|
+
* `InMemoryFormDefinitionRepo.hardDelete`'s doc for why the composition root pairs the two. */
|
|
180
|
+
async deleteAllForDefinition(required) {
|
|
181
|
+
for (const [id, row] of this.rows) {
|
|
182
|
+
if (row.workspaceId === required.workspaceId && row.formDefinitionId === required.formDefinitionId) {
|
|
183
|
+
this.rows.delete(id);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
//# sourceMappingURL=repo.memory.js.map
|