@jini-ai/cms 0.5.2 → 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 +15 -0
- package/README.md +14 -8
- package/dist/comments/errors.d.ts +23 -0
- package/dist/comments/errors.js +25 -0
- package/dist/comments/express/index.d.ts +2 -0
- package/dist/comments/express/index.js +2 -0
- package/dist/comments/express/submit-handler.d.ts +30 -0
- package/dist/comments/express/submit-handler.js +85 -0
- package/dist/comments/hooks.d.ts +39 -0
- package/dist/comments/hooks.js +41 -0
- package/dist/comments/index.d.ts +11 -0
- package/dist/comments/index.js +11 -0
- package/dist/comments/ingress.d.ts +57 -0
- package/dist/comments/ingress.js +173 -0
- package/dist/comments/module.d.ts +94 -0
- package/dist/comments/module.js +73 -0
- package/dist/comments/ports.d.ts +184 -0
- package/dist/comments/ports.js +2 -0
- package/dist/comments/repo.memory.d.ts +70 -0
- package/dist/comments/repo.memory.js +120 -0
- package/dist/comments/sanitize.d.ts +15 -0
- package/dist/comments/sanitize.js +31 -0
- package/dist/comments/spam.heuristic.d.ts +8 -0
- package/dist/comments/spam.heuristic.js +54 -0
- package/dist/comments/sql/index.d.ts +3 -0
- package/dist/comments/sql/index.js +3 -0
- package/dist/comments/sql/repo.d.ts +77 -0
- package/dist/comments/sql/repo.js +190 -0
- package/dist/comments/sql/repo.rows.d.ts +49 -0
- package/dist/comments/sql/repo.rows.js +71 -0
- package/dist/comments/types.d.ts +187 -0
- package/dist/comments/types.js +39 -0
- package/dist/comments/write-service.d.ts +121 -0
- package/dist/comments/write-service.js +144 -0
- package/dist/core/commands/command.d.ts +2 -9
- package/dist/core/commands/command.js +2 -9
- package/dist/core/index.d.ts +2 -1
- package/dist/core/index.js +1 -0
- package/dist/core/keyset-cursor.d.ts +25 -0
- package/dist/core/keyset-cursor.js +20 -0
- package/dist/core/ports.d.ts +30 -0
- package/dist/core/ports.js +0 -1
- package/dist/core/tools/args.d.ts +1 -46
- package/dist/core/tools/args.js +1 -44
- 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/dist/media/index.d.ts +0 -3
- package/dist/media/index.js +1 -4
- package/dist/media/node.d.ts +5 -0
- package/dist/media/node.js +6 -0
- package/dist/redirects/capture.d.ts +80 -0
- package/dist/redirects/capture.js +66 -0
- package/dist/redirects/express/index.d.ts +27 -0
- package/dist/redirects/express/index.js +40 -0
- package/dist/redirects/hit-sink.d.ts +56 -0
- package/dist/redirects/hit-sink.js +44 -0
- package/dist/redirects/host-config/index.d.ts +40 -0
- package/dist/redirects/host-config/index.js +145 -0
- package/dist/redirects/index.d.ts +14 -0
- package/dist/redirects/index.js +14 -0
- package/dist/redirects/matcher.d.ts +58 -0
- package/dist/redirects/matcher.js +144 -0
- package/dist/redirects/order.d.ts +30 -0
- package/dist/redirects/order.js +26 -0
- package/dist/redirects/ports.d.ts +242 -0
- package/dist/redirects/ports.internal.d.ts +57 -0
- package/dist/redirects/ports.internal.js +14 -0
- package/dist/redirects/ports.js +2 -0
- package/dist/redirects/redirects.d.ts +226 -0
- package/dist/redirects/redirects.js +590 -0
- package/dist/redirects/referrer-alias.d.ts +31 -0
- package/dist/redirects/referrer-alias.js +33 -0
- package/dist/redirects/repo.memory.d.ts +51 -0
- package/dist/redirects/repo.memory.js +98 -0
- package/dist/redirects/reserved-destination.d.ts +58 -0
- package/dist/redirects/reserved-destination.js +59 -0
- package/dist/redirects/resolver.d.ts +100 -0
- package/dist/redirects/resolver.js +87 -0
- package/dist/redirects/spa/index.d.ts +30 -0
- package/dist/redirects/spa/index.js +27 -0
- package/dist/redirects/sql/index.d.ts +4 -0
- package/dist/redirects/sql/index.js +4 -0
- package/dist/redirects/sql/repo.d.ts +82 -0
- package/dist/redirects/sql/repo.js +143 -0
- package/dist/redirects/sql/repo.rows.d.ts +49 -0
- package/dist/redirects/sql/repo.rows.js +79 -0
- package/dist/redirects/sql/row-types.d.ts +34 -0
- package/dist/redirects/sql/row-types.js +2 -0
- package/dist/redirects/target-gate.d.ts +30 -0
- package/dist/redirects/target-gate.js +85 -0
- package/dist/redirects/types.d.ts +213 -0
- package/dist/redirects/types.js +42 -0
- package/dist/seo/absolute-url.d.ts +58 -0
- package/dist/seo/absolute-url.js +117 -0
- package/dist/seo/dom/index.d.ts +14 -0
- package/dist/seo/dom/index.js +52 -0
- package/dist/seo/errors.d.ts +35 -0
- package/dist/seo/errors.js +35 -0
- package/dist/seo/feed.d.ts +49 -0
- package/dist/seo/feed.js +61 -0
- package/dist/seo/index.d.ts +13 -0
- package/dist/seo/index.js +13 -0
- package/dist/seo/media.d.ts +57 -0
- package/dist/seo/media.js +134 -0
- package/dist/seo/page-head.d.ts +18 -0
- package/dist/seo/page-head.js +114 -0
- package/dist/seo/ports.d.ts +221 -0
- package/dist/seo/ports.js +27 -0
- package/dist/seo/seo.d.ts +73 -0
- package/dist/seo/seo.js +230 -0
- package/dist/seo/sitemap.d.ts +139 -0
- package/dist/seo/sitemap.js +217 -0
- package/dist/seo/types.d.ts +278 -0
- package/dist/seo/types.js +2 -0
- package/dist/seo/write-service.d.ts +79 -0
- package/dist/seo/write-service.js +273 -0
- package/dist/seo/xml-escape.d.ts +16 -0
- package/dist/seo/xml-escape.js +19 -0
- package/dist/settings/express/cms-adapter.d.ts +20 -0
- package/dist/settings/express/cms-adapter.js +124 -0
- package/dist/settings/express/contracts.d.ts +135 -0
- package/dist/settings/express/contracts.js +2 -0
- package/dist/settings/express/http.d.ts +10 -0
- package/dist/settings/express/http.js +211 -0
- package/dist/settings/express/index.d.ts +5 -0
- package/dist/settings/express/index.js +5 -0
- package/dist/settings/express/shared.d.ts +68 -0
- package/dist/settings/express/shared.js +54 -0
- package/dist/settings/express/stream.d.ts +24 -0
- package/dist/settings/express/stream.js +147 -0
- package/dist/settings/index.d.ts +3 -38
- package/dist/settings/index.js +3 -35
- package/dist/taxonomy/write-service.d.ts +2 -30
- package/dist/widgets/authorize-helper.d.ts +42 -0
- package/dist/widgets/authorize-helper.js +16 -0
- package/dist/widgets/concurrency.d.ts +12 -0
- package/dist/widgets/concurrency.js +44 -0
- package/dist/widgets/config-validation.d.ts +34 -0
- package/dist/widgets/config-validation.js +125 -0
- package/dist/widgets/embed-service.d.ts +133 -0
- package/dist/widgets/embed-service.js +460 -0
- package/dist/widgets/embed-validation.d.ts +23 -0
- package/dist/widgets/embed-validation.js +38 -0
- package/dist/widgets/entry-payload.d.ts +103 -0
- package/dist/widgets/entry-payload.js +160 -0
- package/dist/widgets/errors.d.ts +136 -0
- package/dist/widgets/errors.js +136 -0
- package/dist/widgets/host-ports.d.ts +149 -0
- package/dist/widgets/host-ports.js +2 -0
- package/dist/widgets/html/html-embeds.d.ts +247 -0
- package/dist/widgets/html/html-embeds.js +408 -0
- package/dist/widgets/html/index.d.ts +5 -0
- package/dist/widgets/html/index.js +4 -0
- package/dist/widgets/html/legacy-widget-markers.d.ts +8 -0
- package/dist/widgets/html/legacy-widget-markers.js +45 -0
- package/dist/widgets/html/resolve-html-page-embeds.d.ts +42 -0
- package/dist/widgets/html/resolve-html-page-embeds.js +57 -0
- package/dist/widgets/index.d.ts +20 -0
- package/dist/widgets/index.js +19 -0
- package/dist/widgets/markers/html-attributes.d.ts +98 -0
- package/dist/widgets/markers/html-attributes.js +216 -0
- package/dist/widgets/markers/index.d.ts +3 -0
- package/dist/widgets/markers/index.js +3 -0
- package/dist/widgets/markers/marker.d.ts +496 -0
- package/dist/widgets/markers/marker.js +780 -0
- package/dist/widgets/page-embed-types.d.ts +42 -0
- package/dist/widgets/page-embed-types.js +42 -0
- package/dist/widgets/ports.d.ts +142 -0
- package/dist/widgets/ports.js +2 -0
- package/dist/widgets/read-service.d.ts +81 -0
- package/dist/widgets/read-service.js +79 -0
- package/dist/widgets/region-area-service.d.ts +110 -0
- package/dist/widgets/region-area-service.js +180 -0
- package/dist/widgets/registry.d.ts +217 -0
- package/dist/widgets/registry.js +172 -0
- package/dist/widgets/repo.memory.d.ts +41 -0
- package/dist/widgets/repo.memory.js +48 -0
- package/dist/widgets/resolver-service.d.ts +209 -0
- package/dist/widgets/resolver-service.js +909 -0
- package/dist/widgets/resolvers/contact-form.d.ts +26 -0
- package/dist/widgets/resolvers/contact-form.js +54 -0
- package/dist/widgets/resolvers/create-core-resolvers.d.ts +34 -0
- package/dist/widgets/resolvers/create-core-resolvers.js +11 -0
- package/dist/widgets/resolvers/index.d.ts +65 -0
- package/dist/widgets/resolvers/index.js +155 -0
- package/dist/widgets/resolvers/menu.d.ts +42 -0
- package/dist/widgets/resolvers/menu.js +43 -0
- package/dist/widgets/resolvers/ports.d.ts +82 -0
- package/dist/widgets/resolvers/ports.js +2 -0
- package/dist/widgets/resolvers/recent-entries.d.ts +71 -0
- package/dist/widgets/resolvers/recent-entries.js +165 -0
- package/dist/widgets/resolvers/taxonomy.d.ts +12 -0
- package/dist/widgets/resolvers/taxonomy.js +34 -0
- package/dist/widgets/sql/index.d.ts +3 -0
- package/dist/widgets/sql/index.js +3 -0
- package/dist/widgets/sql/repo.d.ts +41 -0
- package/dist/widgets/sql/repo.js +82 -0
- package/dist/widgets/sql/tables.d.ts +13 -0
- package/dist/widgets/sql/tables.js +2 -0
- package/dist/widgets/types.d.ts +213 -0
- package/dist/widgets/types.js +17 -0
- package/dist/widgets/where-used.d.ts +41 -0
- package/dist/widgets/where-used.js +11 -0
- package/dist/widgets/write-service.d.ts +204 -0
- package/dist/widgets/write-service.js +332 -0
- package/dist/workspace/delete.d.ts +1 -1
- package/package.json +220 -12
|
@@ -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
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { UUID } from "@jini-ai/core/primitives";
|
|
2
|
+
import type { StorageKernel } from "@jini-ai/db/kernel";
|
|
3
|
+
import type { FormsTables, FormDefinitionDatabase, FormSubmissionDatabase } from "./tables.js";
|
|
4
|
+
import type { FormDefinitionRepoPort, FormSubmissionRepoPort } from "../ports.js";
|
|
5
|
+
import type { FormDefinitionRecord, FormSubmissionPage, FormSubmissionRecord } from "../types.js";
|
|
6
|
+
/**
|
|
7
|
+
* @file THE forms repositories: one Kysely query body for every database the storage kernel drives
|
|
8
|
+
* (SQLite, PGlite, Postgres). Every statement goes through `kernel.run` and is awaited.
|
|
9
|
+
*
|
|
10
|
+
* `create`'s slug uniqueness is enforced by the real unique index
|
|
11
|
+
* (`form_definitions_workspace_slug_unique`) — the tie-break of behavior.spec.md §6.1 — but the
|
|
12
|
+
* conflict is detected by a check inside a transaction holding the workspace's forms lock, not by
|
|
13
|
+
* catching the violation: on Postgres a failed statement aborts the surrounding transaction, so a
|
|
14
|
+
* follow-up "is it in the Trash?" query could not run. The index stays as the backstop against a
|
|
15
|
+
* writer outside this process.
|
|
16
|
+
*/
|
|
17
|
+
export declare class SqlFormDefinitionRepo implements FormDefinitionRepoPort {
|
|
18
|
+
protected readonly kernel: StorageKernel<FormDefinitionDatabase>;
|
|
19
|
+
protected readonly tables: FormsTables;
|
|
20
|
+
constructor({ kernel, tables }: {
|
|
21
|
+
kernel: StorageKernel<FormDefinitionDatabase>;
|
|
22
|
+
tables: FormsTables;
|
|
23
|
+
}, _optional?: Record<string, never>);
|
|
24
|
+
/** Trash-aware: `deleted_at IS NULL` excludes a trashed row (`ports.ts`'s fail-closed-reads doc). */
|
|
25
|
+
findById(required: {
|
|
26
|
+
workspaceId: UUID;
|
|
27
|
+
id: UUID;
|
|
28
|
+
}): Promise<FormDefinitionRecord | null>;
|
|
29
|
+
/** Trash-aware, as {@link findById}. */
|
|
30
|
+
findBySlug(required: {
|
|
31
|
+
workspaceId: UUID;
|
|
32
|
+
slug: string;
|
|
33
|
+
}): Promise<FormDefinitionRecord | null>;
|
|
34
|
+
/** Trash-aware, as {@link findById}. */
|
|
35
|
+
list(required: {
|
|
36
|
+
workspaceId: UUID;
|
|
37
|
+
}): Promise<FormDefinitionRecord[]>;
|
|
38
|
+
create(record: FormDefinitionRecord): Promise<void>;
|
|
39
|
+
/**
|
|
40
|
+
* Update-only path for a LIVE row. `version`/`deleted_at` are deliberately excluded from the SET
|
|
41
|
+
* list (only the Trash's compare-and-set may move `version`), and `deleted_at IS NULL` in the
|
|
42
|
+
* WHERE means the UPDATE simply matches zero rows if the record has since been trashed — a stale
|
|
43
|
+
* in-hand copy can never clear a marker it never knew was set.
|
|
44
|
+
*/
|
|
45
|
+
update(record: FormDefinitionRecord): Promise<void>;
|
|
46
|
+
/** Trash-BLIND — see `ports.ts`'s doc for why this is the one deliberate exception. */
|
|
47
|
+
isSlugTaken(required: {
|
|
48
|
+
workspaceId: UUID;
|
|
49
|
+
slug: string;
|
|
50
|
+
}): Promise<boolean>;
|
|
51
|
+
}
|
|
52
|
+
export declare class SqlFormSubmissionRepo implements FormSubmissionRepoPort {
|
|
53
|
+
protected readonly kernel: StorageKernel<FormSubmissionDatabase>;
|
|
54
|
+
protected readonly tables: FormsTables;
|
|
55
|
+
constructor({ kernel, tables }: {
|
|
56
|
+
kernel: StorageKernel<FormSubmissionDatabase>;
|
|
57
|
+
tables: FormsTables;
|
|
58
|
+
}, _optional?: Record<string, never>);
|
|
59
|
+
findById(required: {
|
|
60
|
+
workspaceId: UUID;
|
|
61
|
+
id: UUID;
|
|
62
|
+
}): Promise<FormSubmissionRecord | null>;
|
|
63
|
+
create(record: FormSubmissionRecord): Promise<void>;
|
|
64
|
+
/** The content kernel's transaction; a nested call joins the open one.
|
|
65
|
+
* @complexity O(1) beyond `work`. */
|
|
66
|
+
transaction<T>(work: () => Promise<T>): Promise<T>;
|
|
67
|
+
/**
|
|
68
|
+
* Newest-first (`submitted_at` desc, `id` desc tie-break, behavior.spec.md §2.1). The cursor
|
|
69
|
+
* resumes strictly after the previously returned page's last row on that same ordering; an unknown
|
|
70
|
+
* cursor id is ignored (first page), as before.
|
|
71
|
+
*
|
|
72
|
+
* @complexity O(limit) rows read via the definition index; two statements when a cursor is given.
|
|
73
|
+
*/
|
|
74
|
+
listByDefinition(required: {
|
|
75
|
+
workspaceId: UUID;
|
|
76
|
+
formDefinitionId: UUID;
|
|
77
|
+
limit: number;
|
|
78
|
+
}, optional?: {
|
|
79
|
+
cursor?: string | null;
|
|
80
|
+
}): Promise<FormSubmissionPage>;
|
|
81
|
+
}
|
|
82
|
+
/** The form-definition repo for the injected kernel and required host table names.
|
|
83
|
+
* @complexity O(1); no connection or schema work. */
|
|
84
|
+
export declare function formDefinitionRepoFor<DB>({ kernel, tables }: {
|
|
85
|
+
kernel: StorageKernel<DB>;
|
|
86
|
+
tables: FormsTables;
|
|
87
|
+
}, _optional?: Record<string, never>): SqlFormDefinitionRepo;
|
|
88
|
+
/** The form-submission repo for the injected kernel and required host table names.
|
|
89
|
+
* @complexity O(1); no connection or schema work. */
|
|
90
|
+
export declare function formSubmissionRepoFor<DB>({ kernel, tables }: {
|
|
91
|
+
kernel: StorageKernel<DB>;
|
|
92
|
+
tables: FormsTables;
|
|
93
|
+
}, _optional?: Record<string, never>): SqlFormSubmissionRepo;
|
|
94
|
+
//# sourceMappingURL=repo.d.ts.map
|