create-ailk 0.19.1 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -7
- package/dist/cli.js +35 -0
- package/dist/copy.d.ts +41 -0
- package/dist/copy.js +87 -21
- package/dist/lib/fetch-template-snapshot.d.ts +21 -0
- package/dist/lib/fetch-template-snapshot.js +205 -0
- package/dist/lib/merge-three-way.d.ts +31 -0
- package/dist/lib/merge-three-way.js +65 -0
- package/dist/module-architecture.js +55 -2
- package/dist/programmatic.d.ts +7 -1
- package/dist/programmatic.js +32 -2
- package/dist/spoke-checkout.d.ts +7 -0
- package/dist/spoke-checkout.js +8 -1
- package/dist/surfaces.js +67 -2
- package/dist/template-record.d.ts +59 -0
- package/dist/template-record.js +183 -0
- package/dist/update.d.ts +48 -0
- package/dist/update.js +504 -0
- package/package.json +3 -3
- package/templates/.claude/cloudparity-repo-hooks.json +9 -0
- package/templates/.claude/hooks/architecture-check.sh +8 -0
- package/templates/.claude/rules/loop-contract.md +129 -0
- package/templates/.claude/settings.json +3 -2
- package/templates/.env.example +13 -0
- package/templates/CLAUDE.md +1 -1
- package/templates/CONVENTIONS.md +3 -2
- package/templates/_gitignore +162 -0
- package/templates/apps/api/CLAUDE.md +14 -10
- package/templates/apps/api/package.json +2 -1
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +22 -2
- package/templates/apps/api/src/__tests__/setup.ts +7 -0
- package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
- package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
- package/templates/apps/api/src/lib/proposal-paid-store.ts +49 -0
- package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
- package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +5 -2
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +21 -14
- package/templates/apps/api/src/middleware/auth.ts +8 -4
- package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +290 -82
- package/templates/apps/api/src/routes/webhooks/README.md +33 -5
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +184 -6
- package/templates/apps/api/src/server.ts +14 -2
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
- package/templates/apps/api/src/services/audit-log.ts +72 -10
- package/templates/apps/api/src/services/default-tenant.ts +12 -3
- package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
- package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
- package/templates/apps/api/vercel.json +1 -1
- package/templates/apps/mcp/CLAUDE.md +19 -7
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +95 -8
- package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
- package/templates/apps/mcp/src/tools/proposals.ts +27 -0
- package/templates/apps/mcp/src/workspace-server.ts +16 -0
- package/templates/apps/web/_gitignore +6 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +281 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +28 -83
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +27 -13
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +2 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +102 -18
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +3 -2
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +11 -2
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +5 -24
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +64 -18
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +5 -3
- package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
- package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +13 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
- package/templates/apps/web/app/[locale]/layout.tsx +23 -2
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
- package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
- package/templates/apps/web/app/robots.txt/route.ts +13 -10
- package/templates/apps/web/jest.config.cjs +3 -0
- package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +96 -7
- package/templates/apps/web/lib/account-config.ts +31 -16
- package/templates/apps/web/lib/consent-visitor.ts +103 -0
- package/templates/apps/web/lib/relay.ts +20 -0
- package/templates/apps/web/messages/en.json +67 -4
- package/templates/apps/web/next.config.mjs +8 -2
- package/templates/database/CHANGELOG.md +39 -0
- package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
- package/templates/database/__tests__/person-notes.db.test.ts +249 -0
- package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
- package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
- package/templates/database/_gitignore +8 -0
- package/templates/database/inbox/schema.prisma +38 -12
- package/templates/database/migrations/20261015120000_person_notes/migration.sql +77 -0
- package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
- package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
- package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
- package/templates/database/ops/schema.prisma +16 -0
- package/templates/database/package.json +1 -1
- package/templates/project.yaml +6 -3
- package/versions.json +35 -35
- package/templates/.claude/rules/preserve-existing-style.md +0 -51
|
@@ -57,7 +57,6 @@
|
|
|
57
57
|
},
|
|
58
58
|
"consent": {
|
|
59
59
|
"title": "Cookie Preferences",
|
|
60
|
-
"body": "We use analytics to understand how visitors use this site. We don't sell your data.",
|
|
61
60
|
"acceptLabel": "Accept",
|
|
62
61
|
"rejectLabel": "Reject",
|
|
63
62
|
"policyLinkLabel": "Privacy policy",
|
|
@@ -535,6 +534,7 @@
|
|
|
535
534
|
"signUpSubmit": "Create account",
|
|
536
535
|
"signUpFooterPrompt": "Already have an account?",
|
|
537
536
|
"signUpFooterLink": "Sign in",
|
|
537
|
+
"signUpAgreementsLegend": "Agreements",
|
|
538
538
|
"signUpNotConfiguredSubhead": "Sign-up is not configured for this site yet. Set a provider's environment variables to enable it.",
|
|
539
539
|
"signUpFailed": "We couldn't create your account. Check your details and try again.",
|
|
540
540
|
"providerFailed": "We couldn't start sign-up with that provider. Try again.",
|
|
@@ -622,6 +622,17 @@
|
|
|
622
622
|
"rateLimited": "Too many requests. Try again later.",
|
|
623
623
|
"failed": "We couldn't send your review. Try again."
|
|
624
624
|
}
|
|
625
|
+
},
|
|
626
|
+
"proposal": {
|
|
627
|
+
"caption": "Proposal",
|
|
628
|
+
"price": "Price {amount}",
|
|
629
|
+
"approve": "Approve proposal",
|
|
630
|
+
"heading": "Your decision on version {version}",
|
|
631
|
+
"pay": "Pay now",
|
|
632
|
+
"paying": "Opening payment…",
|
|
633
|
+
"paid": "Paid {date}",
|
|
634
|
+
"paidPending": "Payment received — it may take a minute to show here.",
|
|
635
|
+
"payError": "We couldn't open the payment page. Try again."
|
|
625
636
|
}
|
|
626
637
|
},
|
|
627
638
|
"frame": {
|
|
@@ -771,14 +782,32 @@
|
|
|
771
782
|
"passwordUnavailable": "We couldn't check your password settings."
|
|
772
783
|
},
|
|
773
784
|
"notifications": {
|
|
774
|
-
"title": "Notifications"
|
|
785
|
+
"title": "Notifications",
|
|
786
|
+
"lead": "What we send you.",
|
|
787
|
+
"empty": "There's nothing to set here yet."
|
|
775
788
|
},
|
|
776
789
|
"signIn": {
|
|
777
790
|
"lead": "How you sign in to this site."
|
|
778
791
|
},
|
|
779
792
|
"privacy": {
|
|
793
|
+
"title": "Privacy",
|
|
780
794
|
"lead": "Cookies and your data.",
|
|
781
|
-
"
|
|
795
|
+
"summary": "How we use your data.",
|
|
796
|
+
"policyLink": "Read our privacy policy",
|
|
797
|
+
"cookiesTitle": "Cookies",
|
|
798
|
+
"cookiesAccepted": "Accepted",
|
|
799
|
+
"cookiesDeclined": "Declined",
|
|
800
|
+
"cookiesUnset": "Not chosen yet",
|
|
801
|
+
"cookiesChange": "Change",
|
|
802
|
+
"consentsTitle": "Data use",
|
|
803
|
+
"consentsEmpty": "This site asks for no other data-use consents.",
|
|
804
|
+
"agreementsTitle": "Agreements accepted",
|
|
805
|
+
"agreementsEmpty": "None yet",
|
|
806
|
+
"version": "Version {version}",
|
|
807
|
+
"given": "Given {date}",
|
|
808
|
+
"withdrawn": "Withdrawn {date}",
|
|
809
|
+
"accepted": "Accepted {date}",
|
|
810
|
+
"unavailable": "Your consent choices can't be shown right now.",
|
|
782
811
|
"deletion": "Request account deletion",
|
|
783
812
|
"deletionLead": "Ask the site owner to delete your account and its data. We'll confirm by email.",
|
|
784
813
|
"deletionSentSite": "Sent to the site operator. They'll confirm by email.",
|
|
@@ -809,6 +838,12 @@
|
|
|
809
838
|
}
|
|
810
839
|
}
|
|
811
840
|
},
|
|
841
|
+
"agreements": {
|
|
842
|
+
"titleNew": "Please accept our terms",
|
|
843
|
+
"titleUpdated": "We've updated our terms",
|
|
844
|
+
"accept": "Accept",
|
|
845
|
+
"error": "We couldn't save that. Try again."
|
|
846
|
+
},
|
|
812
847
|
"gate": {
|
|
813
848
|
"emptyHeadline": "Sign-in isn't available yet",
|
|
814
849
|
"emptyDescription": "This site hasn't turned on sign-in for members yet. Check back soon, or contact the site owner.",
|
|
@@ -862,7 +897,26 @@
|
|
|
862
897
|
"heading": "My messages",
|
|
863
898
|
"sentAt": "Sent",
|
|
864
899
|
"file": "File",
|
|
865
|
-
"download": "Download {filename}"
|
|
900
|
+
"download": "Download {filename}",
|
|
901
|
+
"openOnboarding": "Open onboarding"
|
|
902
|
+
},
|
|
903
|
+
"onboarding": {
|
|
904
|
+
"title": "Onboarding",
|
|
905
|
+
"unavailable": "Onboarding isn't open to you here right now.",
|
|
906
|
+
"back": "Back to your account",
|
|
907
|
+
"optional": "Optional",
|
|
908
|
+
"submit": "Send answers",
|
|
909
|
+
"submitting": "Sending…",
|
|
910
|
+
"sent": "Thank you — your answers were sent.",
|
|
911
|
+
"retry": "Retry",
|
|
912
|
+
"errors": {
|
|
913
|
+
"load": "We couldn't load your onboarding questions.",
|
|
914
|
+
"onboardingOff": "Onboarding isn't open to you here right now.",
|
|
915
|
+
"answersInvalid": "Some answers do not fit these questions. Check them and try again.",
|
|
916
|
+
"linkNotHttps": "A link must start with https://.",
|
|
917
|
+
"tooManyRequests": "Too many submissions. Try again later.",
|
|
918
|
+
"failed": "Your answers could not be sent. Try again."
|
|
919
|
+
}
|
|
866
920
|
},
|
|
867
921
|
"subscription": {
|
|
868
922
|
"headline": "Your payment didn't go through",
|
|
@@ -1079,5 +1133,14 @@
|
|
|
1079
1133
|
"sending": "Subscribing…",
|
|
1080
1134
|
"success": "You're subscribed. Thanks for signing up!",
|
|
1081
1135
|
"consentStatement": "I accept the [Terms](terms)."
|
|
1136
|
+
},
|
|
1137
|
+
"unsubscribe": {
|
|
1138
|
+
"title": "Unsubscribe",
|
|
1139
|
+
"lead": "Stop these emails?",
|
|
1140
|
+
"button": "Unsubscribe",
|
|
1141
|
+
"done": "You're unsubscribed from {description}.",
|
|
1142
|
+
"already": "You were already unsubscribed.",
|
|
1143
|
+
"invalid": "This link isn't valid.",
|
|
1144
|
+
"unavailable": "Unsubscribing isn't available right now."
|
|
1082
1145
|
}
|
|
1083
1146
|
}
|
|
@@ -5,6 +5,7 @@ import bundleAnalyzer from "@next/bundle-analyzer";
|
|
|
5
5
|
import { withSentryConfig } from "@sentry/nextjs";
|
|
6
6
|
import createNextIntlPlugin from "next-intl/plugin";
|
|
7
7
|
import { legacyRedirects } from "@working-theory/templates/routes";
|
|
8
|
+
import { parseStealth } from "@working-theory/web-i18n/stealth";
|
|
8
9
|
|
|
9
10
|
// @next/bundle-analyzer — enabled when ANALYZE=true (set by ci-bundle.yml).
|
|
10
11
|
// analyzerMode 'json' writes .next/analyze/client.json (and nodejs.json,
|
|
@@ -16,11 +17,16 @@ const withBundleAnalyzer = bundleAnalyzer({
|
|
|
16
17
|
analyzerMode: "json",
|
|
17
18
|
});
|
|
18
19
|
|
|
19
|
-
// When STEALTH
|
|
20
|
+
// When STEALTH is on, every response carries X-Robots-Tag: noindex, nofollow,
|
|
20
21
|
// noarchive — paired with the <meta robots> tag in app/layout.tsx. Layer 2 of
|
|
21
22
|
// the three-layer stealth posture (#884); new AILK installs default to
|
|
22
23
|
// STEALTH=true so a forked instance doesn't ship indexable by accident.
|
|
23
|
-
|
|
24
|
+
// parseStealth is the one shared parse (#8511): true, 1, yes or on turn it on;
|
|
25
|
+
// false, 0, no, off, empty or unset leave it off; any other value throws here,
|
|
26
|
+
// so `next build` (and a non-standalone `next start`) fails before a page
|
|
27
|
+
// renders. A standalone server.js does not load this file, so there the
|
|
28
|
+
// robots route and every page's <meta robots> throw per request instead.
|
|
29
|
+
const STEALTH = parseStealth(process.env.STEALTH);
|
|
24
30
|
|
|
25
31
|
// next-intl plugin — no explicit path: uses next-intl's default convention
|
|
26
32
|
// lookup (./i18n/request.ts). We use `next build --webpack` (see package.json)
|
|
@@ -1,5 +1,44 @@
|
|
|
1
1
|
# @working-theory/database
|
|
2
2
|
|
|
3
|
+
## 0.41.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#8540](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/8540) [`48ff411`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/48ff41198ef01163412118249f51d64b66d4ce42) Thanks [@claudio-planck](https://github.com/claudio-planck)! - The API's per-IP rate limiter counts in a shared database table when one is configured, so one visitor's limit holds across every concurrent isolate ([#5040](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/5040)).
|
|
8
|
+
|
|
9
|
+
New model `RateLimitCounter` (table `rate_limit_counters`, prefix `rlc`) in the `ops` namespace, holding one row per hashed bucket key. Run the migration before deploying the code that reads it — code deployed first falls back to the per-isolate in-process limiter, safely, until the table exists.
|
|
10
|
+
|
|
11
|
+
- [#8545](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/8545) [`41bc2df`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/41bc2df800c8fe69e2cbb528be68f579abfbd80d) Thanks [@claudio-planck](https://github.com/claudio-planck)! - The Person record redesign ([#8514](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/8514), docs/specs/8514-redesign-the-person-record-page-spec.md).
|
|
12
|
+
|
|
13
|
+
- **database** — `people` gains the team's private note on a client (`notes`, who last saved it, when, and which agent), with format and pairing CHECKs. The same migration then removes `people-read` and `member-pieces-manage` from agents of members, where they were inert; deploy it before the code.
|
|
14
|
+
- **validation** — members now hold `people-read`, `people-write` and `member-pieces-manage`. The new `AGENT_WITHHELD_PERMISSIONS` and `roleAllows` keep every agent off those three by role: an agent holds the grantable two only by an explicit grant, and `people-write` never. New `personNotes*` and `planOfferings*` schemas.
|
|
15
|
+
- **api-client** — `people.notes` and `people.setNotes`.
|
|
16
|
+
- **metadata** — two signed-in workspace tools, `get_person_notes` and `set_person_notes` (thirteen in all).
|
|
17
|
+
- **ui** — `PhoneNumberField` shows its country format as a "Format: …" caption under the field and no longer puts the example in the placeholder.
|
|
18
|
+
- **templates** — `ContactDetailsForm`, the one contact form the account page and the Admin share; Save stays disabled until something changes.
|
|
19
|
+
|
|
20
|
+
- [#8574](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/8574) [`83b7a96`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/83b7a96ccd115230f82e111a9776c8e2136b6bf9) Thanks [@claudio-planck](https://github.com/claudio-planck)! - Standard client onboarding diagnostic and Start onboarding ([#8515](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/8515), docs/specs/8515-standard-client-onboarding-diagnostic-and-spec.md).
|
|
21
|
+
|
|
22
|
+
- `@working-theory/database`: `interactions_outbound_shape` admits one outbound payload, `{"action": "onboarding"}` (migration `20261017120000_outbound_message_action`; no row is rewritten).
|
|
23
|
+
- `@working-theory/validation`: `ONBOARDING_FLOW_SLUG`, `ONBOARDING_FLOW_TITLE` and `onboardingFlowConfig`, the standard onboarding magnet every workspace holds; `OUTBOUND_MESSAGE_ACTIONS` and `onboardingStartBodySchema`; `memberMessageSchema` gains `action`; `memberOnboardingFormResponseSchema`, `memberOnboardingStepSchema`, `memberOnboardingReceiptSchema` and the `onboarding_off` member error code.
|
|
24
|
+
- `@working-theory/api-client`: `member.onboarding` and `member.submitOnboarding`.
|
|
25
|
+
|
|
26
|
+
- [#8572](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/8572) [`16abdb4`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/16abdb45ef60cc46aba35d4179960468b8df1a8b) Thanks [@claudio-planck](https://github.com/claudio-planck)! - Proposals as an artifact kind ([#8516](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/8516), docs/specs/8516-proposals-as-an-artifact-kind-spec.md).
|
|
27
|
+
|
|
28
|
+
- `@working-theory/database`: `pieces` gains `kind` (`deliverable` by default, so every existing piece is unchanged), a proposal's optional price, the client's Checkout Session and the paid stamp, with their CHECKs and one unique index (migration `20261016120000_piece_kind`, DDL only).
|
|
29
|
+
- `@working-theory/validation`: `PIECE_KINDS`, `pieceKindSchema`, `PROPOSAL_CURRENCIES`, `proposalPriceSchema`; `pieceSchema` serves `kind`; the new `proposals.ts` wire (`proposalCreateBodySchema`, `proposalSchema`, `proposalsListResponseSchema`, `proposalResultSchema`, `proposalPaymentsSchema`); `memberArtifactSchema` gains optional `kind`, `price` and `payment`; `isStripeCheckoutUrl`, `memberProposalPaymentResponseSchema` and three member error codes.
|
|
30
|
+
- `@working-theory/api-client`: `people.proposals`, `people.createProposal` and `member.payArtifact`.
|
|
31
|
+
- `@working-theory/metadata`: the `list_proposals` and `create_proposal` workspace tools.
|
|
32
|
+
- `@working-theory/stripe-client`: `createQuotedCheckoutSession` takes optional `expiresAt` and `paymentMethodTypes`; existing callers' sessions are unchanged.
|
|
33
|
+
|
|
34
|
+
### Patch Changes
|
|
35
|
+
|
|
36
|
+
- [#8551](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/8551) [`13e1d6f`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/13e1d6f74ef3db02b9cdedb5f33f84ad1a9a0d66) Thanks [@samuelhenry](https://github.com/samuelhenry)! - Person gains a private team note (notes, notes_updated_at, notes_updated_by, notes_updated_by_agent) and tenant-db gains its one reader and writer ([#8514](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/8514), PR 1 of 2 — the fence half; the API, MCP and Admin surfaces follow in PR 2).
|
|
37
|
+
|
|
38
|
+
- Updated dependencies [[`1b0d07a`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/1b0d07acc0c97f519bf7a2dccf108d6b98c24065), [`48ff411`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/48ff41198ef01163412118249f51d64b66d4ce42), [`98b82b0`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/98b82b0e1cf29be3ba33886ebc7b8294ebff52f0), [`41bc2df`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/41bc2df800c8fe69e2cbb528be68f579abfbd80d), [`83b7a96`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/83b7a96ccd115230f82e111a9776c8e2136b6bf9), [`16abdb4`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/16abdb45ef60cc46aba35d4179960468b8df1a8b)]:
|
|
39
|
+
- @working-theory/validation@0.41.0
|
|
40
|
+
- @working-theory/ids@0.14.0
|
|
41
|
+
|
|
3
42
|
## 0.40.0
|
|
4
43
|
|
|
5
44
|
### Minor Changes
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file outbound-message-action.db.test.ts
|
|
3
|
+
*
|
|
4
|
+
* LIVE-DATABASE proof of the replaced `interactions_outbound_shape` CHECK
|
|
5
|
+
* (#8515, spec docs/specs/8515-standard-client-onboarding-diagnostic-and-spec.md
|
|
6
|
+
* D6; AC-4). Requires DATABASE_URL pointing at a Postgres with the migrations
|
|
7
|
+
* applied — the suite SKIPS when DATABASE_URL is unset, keeping the workspace
|
|
8
|
+
* Jest run DB-free (the repo convention; the `apps/api Route Tests (Postgres)`
|
|
9
|
+
* workflow exercises it).
|
|
10
|
+
*
|
|
11
|
+
* Uses the `pg` driver directly, as `member-contract.db.test.ts` does. An
|
|
12
|
+
* outbound `message` may carry exactly `{"action": "onboarding"}` and no other
|
|
13
|
+
* payload; a payload-free outbound row still lands, and the payload-by-kind
|
|
14
|
+
* rule for inbound rows is unchanged.
|
|
15
|
+
*/
|
|
16
|
+
import { Client } from "pg";
|
|
17
|
+
|
|
18
|
+
const describeDb = process.env.DATABASE_URL ? describe : describe.skip;
|
|
19
|
+
|
|
20
|
+
describeDb("the outbound message action (#8515 D6, AC-4, live DB)", () => {
|
|
21
|
+
let db: Client;
|
|
22
|
+
const WS = "wksp_01920000-8515-7000-8000-0000000000c1";
|
|
23
|
+
const USER = "user_01920000-8515-7000-8000-0000000000c1";
|
|
24
|
+
let person: string;
|
|
25
|
+
|
|
26
|
+
const cleanup = async () => {
|
|
27
|
+
await db.query(`DELETE FROM "interactions" WHERE "workspace_id" = $1`, [WS]);
|
|
28
|
+
await db.query(`DELETE FROM "people" WHERE "workspace_id" = $1`, [WS]);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
beforeAll(async () => {
|
|
32
|
+
db = new Client({ connectionString: process.env.DATABASE_URL });
|
|
33
|
+
await db.connect();
|
|
34
|
+
await cleanup();
|
|
35
|
+
person = (
|
|
36
|
+
await db.query<{ id: string }>(
|
|
37
|
+
`INSERT INTO "people" ("workspace_id", "email", "updated_at") VALUES ($1, 'cleo@example.com', now()) RETURNING "id"`,
|
|
38
|
+
[WS],
|
|
39
|
+
)
|
|
40
|
+
).rows[0]!.id;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterAll(async () => {
|
|
44
|
+
await cleanup();
|
|
45
|
+
await db.end();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const insert = (direction: string, kind: string, payload: unknown) =>
|
|
49
|
+
db.query<{ id: string }>(
|
|
50
|
+
`INSERT INTO "interactions" ("workspace_id", "person_id", "channel", "direction", "kind", "subject", "body", "payload", "created_by")
|
|
51
|
+
VALUES ($1, $2, 'member', $3, $4, 'Your onboarding questions', 'Please answer a few questions.', $5, $6) RETURNING "id"`,
|
|
52
|
+
[WS, person, direction, kind, payload === null ? null : JSON.stringify(payload), USER],
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
it("an outbound message carrying the onboarding action lands", async () => {
|
|
56
|
+
const row = await insert("outbound", "message", { action: "onboarding" });
|
|
57
|
+
expect(row.rows[0]!.id).toMatch(/^intx_/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("an outbound payload-free message still lands", async () => {
|
|
61
|
+
const row = await insert("outbound", "message", null);
|
|
62
|
+
expect(row.rows[0]!.id).toMatch(/^intx_/);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it.each([
|
|
66
|
+
["another action", { action: "invoice" }],
|
|
67
|
+
["a link", { link: "https://x.example" }],
|
|
68
|
+
["the action with an extra key", { action: "onboarding", href: "https://x.example" }],
|
|
69
|
+
])("an outbound message carrying %s fails interactions_outbound_shape", async (_label, payload) => {
|
|
70
|
+
await expect(insert("outbound", "message", payload)).rejects.toMatchObject({
|
|
71
|
+
constraint: "interactions_outbound_shape",
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("an inbound quote without a payload still fails interactions_payload_by_kind", async () => {
|
|
76
|
+
await expect(insert("inbound", "quote", null)).rejects.toMatchObject({
|
|
77
|
+
constraint: "interactions_payload_by_kind",
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file person-notes.db.test.ts
|
|
3
|
+
*
|
|
4
|
+
* LIVE-DATABASE proof of the client record's private notes and the member
|
|
5
|
+
* agent grant reset (#8514, spec
|
|
6
|
+
* docs/specs/8514-redesign-the-person-record-page-spec.md D1, D2, D2b; AC-2
|
|
7
|
+
* and the migration half of AC-3). Requires DATABASE_URL pointing at a
|
|
8
|
+
* Postgres with the migrations applied — every live suite SKIPS when
|
|
9
|
+
* DATABASE_URL is unset (the repo convention; the `apps/api Route Tests
|
|
10
|
+
* (Postgres)` workflow exercises it).
|
|
11
|
+
*
|
|
12
|
+
* Uses the `pg` driver directly, as `person-contact.db.test.ts` does. What it
|
|
13
|
+
* proves that a mock cannot:
|
|
14
|
+
* 1. The notes column's width and the people table's four new CHECKs (the
|
|
15
|
+
* editor's user-id format, the agent's id format, the edit pair, and an
|
|
16
|
+
* agent edit naming its person).
|
|
17
|
+
* 2. The migration's grant-reset half (its own text) removes people-read and
|
|
18
|
+
* member-pieces-manage from an agent of a member only, and a second run
|
|
19
|
+
* changes no row.
|
|
20
|
+
*/
|
|
21
|
+
import { readFileSync } from "node:fs";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
|
|
24
|
+
import { Client } from "pg";
|
|
25
|
+
|
|
26
|
+
const describeDb = process.env.DATABASE_URL ? describe : describe.skip;
|
|
27
|
+
|
|
28
|
+
const MIGRATION = join(
|
|
29
|
+
__dirname,
|
|
30
|
+
"..",
|
|
31
|
+
"migrations",
|
|
32
|
+
"20261015120000_person_notes",
|
|
33
|
+
"migration.sql",
|
|
34
|
+
);
|
|
35
|
+
const RESET_MARK =
|
|
36
|
+
"-- ===== #8514 D2b member agent grant reset (data only; runs after the DDL above) =====";
|
|
37
|
+
|
|
38
|
+
/** The grant-reset half of the single person_notes migration. */
|
|
39
|
+
function resetSql(): string {
|
|
40
|
+
const text = readFileSync(MIGRATION, "utf8");
|
|
41
|
+
const at = text.indexOf(RESET_MARK);
|
|
42
|
+
if (at < 0) throw new Error("the grant-reset marker is missing from the migration");
|
|
43
|
+
return text.slice(at);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const USER_ID = "user_01920000-8514-7000-8000-0000000000a1";
|
|
47
|
+
const AGENT_ID = "agent_01920000-8514-7000-8000-0000000000a2";
|
|
48
|
+
|
|
49
|
+
describe("the person_notes migration file (#8514 D2, D2b)", () => {
|
|
50
|
+
it("is the DDL, then the grant reset: data only, touching the agents table's grants alone", () => {
|
|
51
|
+
const text = readFileSync(MIGRATION, "utf8");
|
|
52
|
+
const at = text.indexOf(RESET_MARK);
|
|
53
|
+
expect(at).toBeGreaterThan(0);
|
|
54
|
+
expect(text.slice(0, at)).toMatch(/ALTER TABLE "people"/);
|
|
55
|
+
expect(text.slice(0, at)).not.toMatch(/UPDATE /);
|
|
56
|
+
const reset = resetSql()
|
|
57
|
+
.split("\n")
|
|
58
|
+
.filter((line) => !line.startsWith("--"))
|
|
59
|
+
.join("\n");
|
|
60
|
+
expect(reset).not.toMatch(/ALTER TABLE|DROP |CREATE /);
|
|
61
|
+
expect(reset).toMatch(/UPDATE "agents"/);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describeDb("the notes columns (#8514 D1, D2, AC-2, live DB)", () => {
|
|
66
|
+
let db: Client;
|
|
67
|
+
const WS = "wksp_01920000-8514-7000-8000-0000000000c1";
|
|
68
|
+
|
|
69
|
+
async function cleanup(): Promise<void> {
|
|
70
|
+
await db.query(`DELETE FROM "people" WHERE "workspace_id" = $1`, [WS]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
beforeAll(async () => {
|
|
74
|
+
db = new Client({ connectionString: process.env.DATABASE_URL });
|
|
75
|
+
await db.connect();
|
|
76
|
+
await cleanup();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
afterAll(async () => {
|
|
80
|
+
await cleanup();
|
|
81
|
+
await db.end();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
let counter = 0;
|
|
85
|
+
const email = () => `p8514-${(counter += 1)}@example.com`;
|
|
86
|
+
|
|
87
|
+
/** Insert one Person with the given notes columns on top of the required pair. */
|
|
88
|
+
const insertPerson = async (cols: Record<string, unknown> = {}) => {
|
|
89
|
+
const all: Record<string, unknown> = {
|
|
90
|
+
workspace_id: WS,
|
|
91
|
+
email: email(),
|
|
92
|
+
updated_at: new Date(),
|
|
93
|
+
...cols,
|
|
94
|
+
};
|
|
95
|
+
const names = Object.keys(all);
|
|
96
|
+
return db.query<{ id: string }>(
|
|
97
|
+
`INSERT INTO "people" (${names.map((n) => `"${n}"`).join(", ")})
|
|
98
|
+
VALUES (${names.map((_, i) => `$${i + 1}`).join(", ")}) RETURNING "id"`,
|
|
99
|
+
names.map((n) => all[n]),
|
|
100
|
+
);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const AT = new Date("2026-10-15T12:00:00.000Z");
|
|
104
|
+
|
|
105
|
+
it("a Person without notes reads all four columns as NULL", async () => {
|
|
106
|
+
const { rows } = await insertPerson();
|
|
107
|
+
const read = await db.query(
|
|
108
|
+
`SELECT "notes", "notes_updated_at", "notes_updated_by", "notes_updated_by_agent" FROM "people" WHERE "id" = $1`,
|
|
109
|
+
[rows[0]!.id],
|
|
110
|
+
);
|
|
111
|
+
expect(read.rows[0]).toEqual({
|
|
112
|
+
notes: null,
|
|
113
|
+
notes_updated_at: null,
|
|
114
|
+
notes_updated_by: null,
|
|
115
|
+
notes_updated_by_agent: null,
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("a 5000-character note saves and a 5001-character one fails", async () => {
|
|
120
|
+
await expect(
|
|
121
|
+
insertPerson({ notes: "n".repeat(5000), notes_updated_at: AT, notes_updated_by: USER_ID }),
|
|
122
|
+
).resolves.toBeDefined();
|
|
123
|
+
await expect(
|
|
124
|
+
insertPerson({ notes: "n".repeat(5001), notes_updated_at: AT, notes_updated_by: USER_ID }),
|
|
125
|
+
).rejects.toThrow(/value too long/);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("a user_ editor with a timestamp succeeds; an agent_ editor fails the format CHECK", async () => {
|
|
129
|
+
await expect(
|
|
130
|
+
insertPerson({ notes: "x", notes_updated_at: AT, notes_updated_by: USER_ID }),
|
|
131
|
+
).resolves.toBeDefined();
|
|
132
|
+
await expect(
|
|
133
|
+
insertPerson({ notes: "x", notes_updated_at: AT, notes_updated_by: AGENT_ID }),
|
|
134
|
+
).rejects.toThrow(/people_notes_updated_by_format/);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("a timestamp without an editor, or the reverse, fails the edit pair", async () => {
|
|
138
|
+
await expect(insertPerson({ notes: "x", notes_updated_at: AT })).rejects.toThrow(
|
|
139
|
+
/people_notes_edit_pair/,
|
|
140
|
+
);
|
|
141
|
+
await expect(insertPerson({ notes: "x", notes_updated_by: USER_ID })).rejects.toThrow(
|
|
142
|
+
/people_notes_edit_pair/,
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("an agent edit carries an agent_ id and its person", async () => {
|
|
147
|
+
await expect(
|
|
148
|
+
insertPerson({
|
|
149
|
+
notes: "x",
|
|
150
|
+
notes_updated_at: AT,
|
|
151
|
+
notes_updated_by: USER_ID,
|
|
152
|
+
notes_updated_by_agent: AGENT_ID,
|
|
153
|
+
}),
|
|
154
|
+
).resolves.toBeDefined();
|
|
155
|
+
await expect(
|
|
156
|
+
insertPerson({
|
|
157
|
+
notes: "x",
|
|
158
|
+
notes_updated_at: AT,
|
|
159
|
+
notes_updated_by: USER_ID,
|
|
160
|
+
notes_updated_by_agent: USER_ID,
|
|
161
|
+
}),
|
|
162
|
+
).rejects.toThrow(/people_notes_updated_by_agent_format/);
|
|
163
|
+
await expect(insertPerson({ notes_updated_by_agent: AGENT_ID })).rejects.toThrow(
|
|
164
|
+
/people_notes_agent_has_person/,
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describeDb("the member agent grant reset (#8514 D2b, AC-3, live DB)", () => {
|
|
170
|
+
let db: Client;
|
|
171
|
+
let organizationId: string;
|
|
172
|
+
let workspaceId: string;
|
|
173
|
+
let ownerUserId: string;
|
|
174
|
+
let ownerMembershipId: string;
|
|
175
|
+
let memberMembershipId: string;
|
|
176
|
+
let adminMembershipId: string;
|
|
177
|
+
let hashSeq = 0;
|
|
178
|
+
|
|
179
|
+
const nextHash = () => (hashSeq += 1).toString(16).padStart(64, "0");
|
|
180
|
+
|
|
181
|
+
async function one(sql: string, params: unknown[]): Promise<string> {
|
|
182
|
+
return (await db.query<{ id: string }>(sql, params)).rows[0]!.id;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const insertUser = (tag: string) =>
|
|
186
|
+
one(`INSERT INTO "users" ("email", "updated_at") VALUES ($1, now()) RETURNING "id"`, [
|
|
187
|
+
`grants8514-${tag}@example.test`,
|
|
188
|
+
]);
|
|
189
|
+
|
|
190
|
+
const insertMembership = (userId: string, role: string) =>
|
|
191
|
+
one(
|
|
192
|
+
`INSERT INTO "memberships" ("organization_id", "user_id", "role") VALUES ($1, $2, $3::"Role") RETURNING "id"`,
|
|
193
|
+
[organizationId, userId, role],
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
const insertAgent = (responsibleMembershipId: string, grants: string[]) =>
|
|
197
|
+
one(
|
|
198
|
+
`INSERT INTO "agents" ("organization_id", "workspace_id", "responsible_membership_id", "created_by_user_id",
|
|
199
|
+
"key_issued_to_membership_id", "credential_hash", "grants", "name", "credential_prefix", "updated_at")
|
|
200
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, 'client helper', 'ailk_agt_abcdefgh', now()) RETURNING "id"`,
|
|
201
|
+
[organizationId, workspaceId, responsibleMembershipId, ownerUserId, ownerMembershipId, nextHash(), grants],
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const grantsOf = async (id: string) =>
|
|
205
|
+
(await db.query<{ grants: string[] }>(`SELECT "grants" FROM "agents" WHERE "id" = $1`, [id])).rows[0]!.grants;
|
|
206
|
+
|
|
207
|
+
beforeAll(async () => {
|
|
208
|
+
db = new Client({ connectionString: process.env.DATABASE_URL });
|
|
209
|
+
await db.connect();
|
|
210
|
+
organizationId = await one(
|
|
211
|
+
`INSERT INTO "organizations" ("name", "slug", "updated_at") VALUES ($1, $1, now()) RETURNING "id"`,
|
|
212
|
+
["grants8514-org"],
|
|
213
|
+
);
|
|
214
|
+
workspaceId = await one(
|
|
215
|
+
`INSERT INTO "workspaces" ("organization_id", "name", "slug", "updated_at") VALUES ($1, $2, $2, now()) RETURNING "id"`,
|
|
216
|
+
[organizationId, "grants8514-ws"],
|
|
217
|
+
);
|
|
218
|
+
ownerUserId = await insertUser("owner");
|
|
219
|
+
ownerMembershipId = await insertMembership(ownerUserId, "owner");
|
|
220
|
+
memberMembershipId = await insertMembership(await insertUser("member"), "member");
|
|
221
|
+
adminMembershipId = await insertMembership(await insertUser("admin"), "admin");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
afterAll(async () => {
|
|
225
|
+
await db.query(`DELETE FROM "organizations" WHERE "slug" LIKE 'grants8514-%'`);
|
|
226
|
+
await db.query(`DELETE FROM "users" WHERE "email" LIKE 'grants8514-%'`);
|
|
227
|
+
await db.end();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("removes the two client grants from a member's agent only, and is idempotent", async () => {
|
|
231
|
+
const membersAgent = await insertAgent(memberMembershipId, [
|
|
232
|
+
"people-read",
|
|
233
|
+
"member-pieces-manage",
|
|
234
|
+
"content-edit",
|
|
235
|
+
]);
|
|
236
|
+
const adminsAgent = await insertAgent(adminMembershipId, ["people-read"]);
|
|
237
|
+
const sql = resetSql();
|
|
238
|
+
|
|
239
|
+
const first = await db.query(sql);
|
|
240
|
+
expect(first.rowCount).toBeGreaterThanOrEqual(1);
|
|
241
|
+
expect(await grantsOf(membersAgent)).toEqual(["content-edit"]);
|
|
242
|
+
expect(await grantsOf(adminsAgent)).toEqual(["people-read"]);
|
|
243
|
+
|
|
244
|
+
const second = await db.query(sql);
|
|
245
|
+
expect(second.rowCount).toBe(0);
|
|
246
|
+
expect(await grantsOf(membersAgent)).toEqual(["content-edit"]);
|
|
247
|
+
expect(await grantsOf(adminsAgent)).toEqual(["people-read"]);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file piece-kind-migration.test.ts
|
|
3
|
+
*
|
|
4
|
+
* DB-free proof of the piece_kind migration's shape (#8516, spec
|
|
5
|
+
* docs/specs/8516-proposals-as-an-artifact-kind-spec.md D2, AC-1). Reads the
|
|
6
|
+
* migration's own text: it is DDL only — eight `ADD COLUMN`s on "pieces", one
|
|
7
|
+
* unique index and CHECKs added `NOT VALID` then `VALIDATE`d — with no
|
|
8
|
+
* `UPDATE`, `INSERT` or `DELETE`, so no existing row is rewritten, and `kind`
|
|
9
|
+
* defaults to `deliverable` as a constant. The live-database half is
|
|
10
|
+
* `piece-kind.db.test.ts`.
|
|
11
|
+
*/
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
const MIGRATION = join(__dirname, "..", "migrations", "20261016120000_piece_kind", "migration.sql");
|
|
16
|
+
|
|
17
|
+
/** The migration's statements, comments stripped, whitespace collapsed. */
|
|
18
|
+
function statements(): string[] {
|
|
19
|
+
const text = readFileSync(MIGRATION, "utf8")
|
|
20
|
+
.split("\n")
|
|
21
|
+
.filter((line) => !line.trimStart().startsWith("--"))
|
|
22
|
+
.join("\n");
|
|
23
|
+
return text
|
|
24
|
+
.split(";")
|
|
25
|
+
.map((s) => s.replace(/\s+/g, " ").trim())
|
|
26
|
+
.filter((s) => s.length > 0);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("the piece_kind migration (#8516 D2, AC-1)", () => {
|
|
30
|
+
it("is only ADD COLUMN on pieces, one unique index, and NOT VALID CHECKs then VALIDATE", () => {
|
|
31
|
+
const shapes = statements().map((s) => {
|
|
32
|
+
if (/^ALTER TABLE "pieces" ADD COLUMN /.test(s)) return "add-column";
|
|
33
|
+
if (/^CREATE UNIQUE INDEX "pieces_checkout_session_id_key" ON "pieces"/.test(s)) return "index";
|
|
34
|
+
if (/^ALTER TABLE "pieces" ADD CONSTRAINT "pieces_[a-z_]+" CHECK \(.*\) NOT VALID$/.test(s)) {
|
|
35
|
+
return "check";
|
|
36
|
+
}
|
|
37
|
+
if (/^ALTER TABLE "pieces" VALIDATE CONSTRAINT "pieces_[a-z_]+"$/.test(s)) return "validate";
|
|
38
|
+
return `unexpected: ${s}`;
|
|
39
|
+
});
|
|
40
|
+
expect(shapes.filter((s) => s.startsWith("unexpected"))).toEqual([]);
|
|
41
|
+
expect(shapes.filter((s) => s === "add-column")).toHaveLength(1);
|
|
42
|
+
expect(shapes.filter((s) => s === "index")).toHaveLength(1);
|
|
43
|
+
expect(shapes.filter((s) => s === "check")).toHaveLength(11);
|
|
44
|
+
expect(shapes.filter((s) => s === "validate")).toHaveLength(11);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("writes no row: no UPDATE, INSERT or DELETE", () => {
|
|
48
|
+
for (const s of statements()) expect(s).not.toMatch(/\b(UPDATE|INSERT|DELETE)\b/i);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("adds kind as VARCHAR(16) NOT NULL DEFAULT 'deliverable' and seven nullable columns", () => {
|
|
52
|
+
const add = statements().find((s) => s.startsWith('ALTER TABLE "pieces" ADD COLUMN'));
|
|
53
|
+
expect(add).toContain(`"kind" VARCHAR(16) NOT NULL DEFAULT 'deliverable'`);
|
|
54
|
+
for (const column of [
|
|
55
|
+
`"price_amount" INTEGER`,
|
|
56
|
+
`"price_currency" CHAR(3)`,
|
|
57
|
+
`"checkout_session_id" TEXT`,
|
|
58
|
+
`"checkout_url" VARCHAR(2048)`,
|
|
59
|
+
`"checkout_expires_at" TIMESTAMP(3)`,
|
|
60
|
+
`"paid_at" TIMESTAMP(3)`,
|
|
61
|
+
`"paid_session_id" TEXT`,
|
|
62
|
+
]) {
|
|
63
|
+
expect(add).toMatch(new RegExp(`${column.replace(/[()]/g, "\\$&")}(,|$)`));
|
|
64
|
+
}
|
|
65
|
+
expect(add).not.toMatch(/DEFAULT 'proposal'/);
|
|
66
|
+
});
|
|
67
|
+
});
|