@elytracms/studio 0.0.96 → 0.0.98

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/convex/assets.ts +16 -2
  2. package/convex/cms.ts +153 -13
  3. package/convex/delivery-folded-drift.test.ts +104 -0
  4. package/convex/delivery.ts +24 -8
  5. package/convex/depth-codec-lookalike.test.ts +192 -0
  6. package/convex/fixture-accounts.test.ts +305 -0
  7. package/convex/fixtureAccounts.ts +298 -0
  8. package/convex/incarnation.test.ts +149 -0
  9. package/convex/ingest-attempt.test.ts +161 -0
  10. package/convex/ingest.test.ts +327 -9
  11. package/convex/ingest.ts +242 -24
  12. package/convex/prototype-key.test.ts +92 -0
  13. package/convex/receipt-races.test.ts +184 -0
  14. package/convex/records-zod-copy.test.ts +76 -0
  15. package/convex/records-zod-refinements.convex.test.ts +94 -0
  16. package/convex/schema.ts +20 -1
  17. package/convex/strict-delete.test.ts +129 -0
  18. package/dist/components/cms/cms-store.d.ts +6 -5
  19. package/dist/components/cms/cms-store.js +14 -34
  20. package/dist/components/cms/cms-store.js.map +1 -1
  21. package/dist/components/cms/field-control.d.ts +12 -1
  22. package/dist/components/cms/field-control.js +16 -1
  23. package/dist/components/cms/field-control.js.map +1 -1
  24. package/dist/components/cms/import-batch.d.ts +23 -0
  25. package/dist/components/cms/import-batch.js +43 -0
  26. package/dist/components/cms/import-batch.js.map +1 -0
  27. package/dist/components/cms/rich-text/editor.js +13 -10
  28. package/dist/components/cms/rich-text/editor.js.map +1 -1
  29. package/dist/components/cms/rich-text/value.d.ts +9 -0
  30. package/dist/components/cms/rich-text/value.js +13 -0
  31. package/dist/components/cms/rich-text/value.js.map +1 -1
  32. package/dist/components/cms/use-document-draft.d.ts +76 -4
  33. package/dist/components/cms/use-document-draft.js +94 -25
  34. package/dist/components/cms/use-document-draft.js.map +1 -1
  35. package/dist/components/records/record-lens.js.map +1 -1
  36. package/dist/lib/collection-view/collection-view-source.d.ts +5 -2
  37. package/dist/lib/collection-view/collection-view-source.js.map +1 -1
  38. package/dist/lib/engine-version.d.ts +1 -1
  39. package/dist/lib/engine-version.js +1 -1
  40. package/dist/lib/engine-version.js.map +1 -1
  41. package/dist/lib/i18n/messages.de.js +5 -0
  42. package/dist/lib/i18n/messages.de.js.map +1 -1
  43. package/dist/lib/i18n/messages.en.d.ts +5 -0
  44. package/dist/lib/i18n/messages.en.js +5 -0
  45. package/dist/lib/i18n/messages.en.js.map +1 -1
  46. package/dist/lib/operations/cms-persistence.d.ts +49 -5
  47. package/dist/lib/operations/cms-persistence.js +237 -47
  48. package/dist/lib/operations/cms-persistence.js.map +1 -1
  49. package/dist/lib/persistence/convex-adapter.js +27 -1
  50. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  51. package/dist/lib/persona/labels.js +2 -0
  52. package/dist/lib/persona/labels.js.map +1 -1
  53. package/package.json +5 -3
@@ -0,0 +1,298 @@
1
+ import { createAccount, modifyAccountCredentials } from '@convex-dev/auth/server'
2
+ import { internalActionGeneric, makeFunctionReference } from 'convex/server'
3
+ import type { AnyDataModel, GenericMutationCtx } from 'convex/server'
4
+ import { v } from 'convex/values'
5
+ import type { GenericId } from 'convex/values'
6
+ import { DEV_USERS, DEV_USER_ROLES, classifyInviteSignup, normalizeInviteEmail } from '@elytracms/core/persistence'
7
+ import { gateSessionCreation } from './auth'
8
+ import { assertDeclaredRole, recordAuthorityAudit } from './authority'
9
+ import { internalMutation } from './functions'
10
+ import type { MutationCtx } from './functions'
11
+ import { purgeOrphanCredentials } from './invites'
12
+
13
+ /**
14
+ * EC-672 — fixture accounts for the two DEMO deployments (owner-ratified 2026-10-28: both
15
+ * demo deployments are agent-owned, re-seedable fixtures). Provisions the seed's dev users
16
+ * (`DEV_USER_ROLES`: admin, editor, viewer — the same three the playground signs in as) as
17
+ * real Convex Auth accounts, so an agent's connected studio and `elytra seed --drop` can sign
18
+ * in, and two browsers are two users.
19
+ *
20
+ * **Confined structurally, not by a check.** No consumer deployment has these functions: a
21
+ * Convex deployment runs only what its own `convex/` directory exports, and only the in-repo
22
+ * demo (`apps/demo/studio/convex/fixtureAccounts.ts`) re-exports this module — the
23
+ * `create-elytra` template and both kits re-export nothing of it, which
24
+ * `packages/create-elytra/test/template-fixture-accounts.test.js` pins. Belt and braces, every
25
+ * function here refuses unless the deployment carries `ELYTRA_FIXTURE_ACCOUNTS=1` (set on the
26
+ * two demo deployments only).
27
+ *
28
+ * **The password is a per-deployment secret**, `ELYTRA_FIXTURE_PASSWORD`, read from the
29
+ * deployment env — never a literal in this repo. The playground's `LOCAL_DEV_CREDENTIALS`
30
+ * ship in the published studio and are shown on the playground sign-in screen, so a real
31
+ * account carrying one of them would have a publicly known password (critic-on-plan,
32
+ * wave-0098). The owner sets the secret once per deployment from a generator piped straight
33
+ * into `npx convex env set`; the agent studio's connected mode and `pnpm demo:reseed` read it
34
+ * with `npx convex env get` into a child process env, never printed.
35
+ *
36
+ * **Through the provider's own path, so the role/invite gate applies.** Per account:
37
+ *
38
+ * 1. `prepare` (one transaction) does what an admin's invite does (`invites:create`): it
39
+ * refuses a role the deployed policy does not declare (`assertDeclaredRole`), purges
40
+ * member-less credentials for the email (EC-358 — an invite never adopts pre-existing
41
+ * credentials; a crashed earlier run's half-made account is one), and writes the invite
42
+ * when the deployment's own decision (`classifyInviteSignup`) would otherwise reject the
43
+ * sign-up. It is the invite's equivalent because it is INTERNAL: only a holder of the
44
+ * deployment's admin key (the CLI, the dashboard) can run it — strictly above
45
+ * `project.administer`, the capability `invites:create` requires.
46
+ * 2. `createAccount` (Convex Auth) — exactly the call the Password provider's `signUp` flow
47
+ * makes — creates the account; the engine's account-creation gate
48
+ * (`afterUserCreatedOrUpdated` → `gateAccountCreation`) runs inside it and reads that
49
+ * invite, unmodified. An existing account whose secret matches is returned untouched (the
50
+ * library's own idempotency); one whose secret differs means the owner rotated the
51
+ * deployment secret, and `modifyAccountCredentials` follows it.
52
+ * 3. `settle` grants the membership by the gate every sign-in runs (`gateSessionCreation`:
53
+ * bootstrap on an empty deployment, grant + consume the invite otherwise), restores a
54
+ * drifted fixture role (audited), and drops a leftover invite for the email.
55
+ *
56
+ * Idempotent: a second run creates, rotates, grants and writes nothing — every account
57
+ * reports `unchanged`. Creating no session, it never signs anyone in.
58
+ *
59
+ * Run from the demo studio dir (the reseed script does it for you):
60
+ * `CONVEX_DEPLOYMENT=dev:<name> npx convex run fixtureAccounts:provision`.
61
+ */
62
+
63
+ /** The deployment env flag every function here requires, exactly `'1'`. */
64
+ export const FIXTURE_ACCOUNTS_FLAG = 'ELYTRA_FIXTURE_ACCOUNTS'
65
+ /** The deployment env var holding the fixture accounts' password (a per-deployment secret). */
66
+ export const FIXTURE_PASSWORD_ENV = 'ELYTRA_FIXTURE_PASSWORD'
67
+ /**
68
+ * The secret's minimum length. Above the Password provider's own 8 (which `createAccount`
69
+ * does not apply — only the provider's `signUp` flow validates): this password guards an
70
+ * admin on a deployment behind a public site, so a short one is a misconfiguration, refused.
71
+ */
72
+ export const FIXTURE_PASSWORD_MIN_LENGTH = 16
73
+ /** The provider the accounts belong to — the studio's default `Password` (`defineStudioAuth`). */
74
+ const PROVIDER = 'password'
75
+ /** The audit actor of a fixture role restore (no studio user performs it). */
76
+ const AUDIT_ACTOR = 'fixture-accounts'
77
+
78
+ /** Where the demo re-exports this module (`convex/fixtureAccounts.ts`); the action's sub-calls. */
79
+ const prepareRef = makeFunctionReference<'mutation', { email: string; role: string }, PrepareOutcome>(
80
+ 'fixtureAccounts:prepare',
81
+ )
82
+ const settleRef = makeFunctionReference<'mutation', { email: string; role: string }, SettleOutcome>(
83
+ 'fixtureAccounts:settle',
84
+ )
85
+
86
+ type Env = Record<string, string | undefined>
87
+
88
+ /** One fixture account: a seeded dev user's email and role. */
89
+ export interface FixtureAccount {
90
+ readonly email: string
91
+ readonly role: string
92
+ }
93
+
94
+ /**
95
+ * The fixture accounts, from the seed's ONE declaration of its dev users and their roles
96
+ * (`DEV_USER_ROLES` → `DEV_USERS`), in that order — admin first, so on an empty deployment
97
+ * the admin is the bootstrap principal. The agent scripts select an account by the same
98
+ * role table, so there is no second list to drift.
99
+ */
100
+ export function fixtureAccountList(): FixtureAccount[] {
101
+ const users = Object.values(DEV_USERS)
102
+ return DEV_USER_ROLES.map(({ userId, role }) => {
103
+ const user = users.find((candidate) => candidate.id === userId)
104
+ if (!user) throw new Error(`fixture-accounts: DEV_USER_ROLES names ${userId}, which DEV_USERS does not declare`)
105
+ return { email: normalizeInviteEmail(user.email), role }
106
+ })
107
+ }
108
+
109
+ /** Refuse unless this deployment opted in. Every function here calls it first. */
110
+ export function assertFixtureAccountsEnabled(env: Env): void {
111
+ if (env[FIXTURE_ACCOUNTS_FLAG] !== '1') {
112
+ throw new Error(
113
+ `fixture-accounts: refused — this deployment does not carry ${FIXTURE_ACCOUNTS_FLAG}=1. Fixture accounts exist only on the two demo deployments; nothing was written.`,
114
+ )
115
+ }
116
+ }
117
+
118
+ /** The fixture password from the deployment env, or a refusal naming what is missing (never the value). */
119
+ export function readFixturePassword(env: Env): string {
120
+ assertFixtureAccountsEnabled(env)
121
+ const password = env[FIXTURE_PASSWORD_ENV]
122
+ if (password === undefined || password === '') {
123
+ throw new Error(
124
+ `fixture-accounts: refused — ${FIXTURE_PASSWORD_ENV} is not set on this deployment. The owner sets it once, generated and piped (never typed): \`sh -c 'openssl rand -base64 24 | xargs npx convex env set ${FIXTURE_PASSWORD_ENV}'\` with the deployment inline. Nothing was written.`,
125
+ )
126
+ }
127
+ if (password.length < FIXTURE_PASSWORD_MIN_LENGTH) {
128
+ throw new Error(
129
+ `fixture-accounts: refused — ${FIXTURE_PASSWORD_ENV} is shorter than ${FIXTURE_PASSWORD_MIN_LENGTH} characters. Nothing was written.`,
130
+ )
131
+ }
132
+ return password
133
+ }
134
+
135
+ /** What `prepare` found and did for one account. */
136
+ export interface PrepareOutcome {
137
+ /** Member-less credentials for the email that were purged (EC-358). */
138
+ readonly purgedOrphans: number
139
+ /** Whether a password account for the email exists after the purge. */
140
+ readonly accountExists: boolean
141
+ /** The invite step: written / role corrected / already right / not needed (a member, or the bootstrap principal). */
142
+ readonly invite: 'written' | 'updated' | 'present' | 'not-needed'
143
+ }
144
+
145
+ /** What `settle` did for one account. */
146
+ export interface SettleOutcome {
147
+ readonly membership: 'bootstrapped' | 'granted' | 'unchanged' | 'role-restored'
148
+ /** A leftover invite for the email that was removed (the account is a member now). */
149
+ readonly inviteRemoved: boolean
150
+ }
151
+
152
+ /** One account's line in the `provision` report. */
153
+ export interface ProvisionedAccount extends FixtureAccount {
154
+ readonly account: 'created' | 'unchanged' | 'password-rotated'
155
+ readonly membership: SettleOutcome['membership']
156
+ readonly invite: PrepareOutcome['invite']
157
+ readonly purgedOrphans: number
158
+ }
159
+
160
+ async function findPasswordAccount(ctx: Pick<MutationCtx, 'db'>, email: string) {
161
+ return ctx.db
162
+ .query('authAccounts')
163
+ .withIndex('providerAndAccountId', (q) => q.eq('provider', PROVIDER).eq('providerAccountId', email))
164
+ .unique()
165
+ }
166
+
167
+ async function findMember(ctx: Pick<MutationCtx, 'db'>, userId: string) {
168
+ return ctx.db
169
+ .query('members')
170
+ .withIndex('by_user', (q) => q.eq('userId', userId))
171
+ .first()
172
+ }
173
+
174
+ async function findInvite(ctx: Pick<MutationCtx, 'db'>, email: string) {
175
+ return ctx.db
176
+ .query('invites')
177
+ .withIndex('by_email', (q) => q.eq('email', email))
178
+ .first()
179
+ }
180
+
181
+ const accountArgs = { email: v.string(), role: v.string() }
182
+
183
+ /** Step 1 — the admin-invite equivalent (see the module doc). Internal: admin key only. */
184
+ export const prepare = internalMutation({
185
+ args: accountArgs,
186
+ handler: async (ctx, args): Promise<PrepareOutcome> => {
187
+ assertFixtureAccountsEnabled(process.env)
188
+ assertDeclaredRole(args.role)
189
+ const email = normalizeInviteEmail(args.email)
190
+ const purgedOrphans = await purgeOrphanCredentials(ctx, email)
191
+ const account = await findPasswordAccount(ctx, email)
192
+ const member = account ? await findMember(ctx, String(account.userId)) : null
193
+ if (member) return { purgedOrphans, accountExists: true, invite: 'not-needed' }
194
+
195
+ const invite = await findInvite(ctx, email)
196
+ const decision = classifyInviteSignup({
197
+ hasAnyMember: (await ctx.db.query('members').first()) !== null,
198
+ existingRole: null,
199
+ invite: invite ? { role: invite.role } : null,
200
+ })
201
+ const now = new Date().toISOString()
202
+ if (decision.kind === 'bootstrap-admin') {
203
+ return { purgedOrphans, accountExists: account !== null, invite: 'not-needed' }
204
+ }
205
+ if (invite && invite.role === args.role) {
206
+ return { purgedOrphans, accountExists: account !== null, invite: 'present' }
207
+ }
208
+ if (invite) {
209
+ await ctx.db.patch(invite._id, { role: args.role, updatedAt: now })
210
+ return { purgedOrphans, accountExists: account !== null, invite: 'updated' }
211
+ }
212
+ await ctx.db.insert('invites', { email, role: args.role, createdAt: now, updatedAt: now })
213
+ return { purgedOrphans, accountExists: account !== null, invite: 'written' }
214
+ },
215
+ })
216
+
217
+ /** Step 3 — grant by the sign-in gate, restore a drifted role, drop a leftover invite. Internal. */
218
+ export const settle = internalMutation({
219
+ args: accountArgs,
220
+ handler: async (ctx, args): Promise<SettleOutcome> => {
221
+ assertFixtureAccountsEnabled(process.env)
222
+ const email = normalizeInviteEmail(args.email)
223
+ const account = await findPasswordAccount(ctx, email)
224
+ if (!account) throw new Error(`fixture-accounts: no password account for ${email} after createAccount`)
225
+ const userId = String(account.userId)
226
+ const before = await findMember(ctx, userId)
227
+ const anyMemberBefore = before !== null || (await ctx.db.query('members').first()) !== null
228
+
229
+ // The gate every sign-in runs (EC-324): bootstrap / grant + consume / existing / reject.
230
+ await gateSessionCreation(ctx as unknown as GenericMutationCtx<AnyDataModel>, {
231
+ userId: account.userId as GenericId<'users'>,
232
+ })
233
+ const member = await findMember(ctx, userId)
234
+ if (!member) throw new Error(`fixture-accounts: ${email} is not a member after the sign-in gate`)
235
+
236
+ let membership: SettleOutcome['membership'] = before ? 'unchanged' : anyMemberBefore ? 'granted' : 'bootstrapped'
237
+ if (member.role !== args.role) {
238
+ assertDeclaredRole(args.role)
239
+ await ctx.db.patch(member._id, { role: args.role, updatedAt: new Date().toISOString() })
240
+ await recordAuthorityAudit(ctx, {
241
+ actorId: AUDIT_ACTOR,
242
+ targetUserId: userId,
243
+ op: 'members.set',
244
+ outcome: 'allowed',
245
+ rolesGranted: [args.role],
246
+ detail: `fixture role restored (was ${member.role})`,
247
+ })
248
+ membership = 'role-restored'
249
+ }
250
+
251
+ const leftover = await findInvite(ctx, email)
252
+ if (leftover) await ctx.db.delete(leftover._id)
253
+ return { membership, inviteRemoved: leftover !== null }
254
+ },
255
+ })
256
+
257
+ /**
258
+ * `fixtureAccounts:provision` — the one entry point: provisions every fixture account
259
+ * (prepare → `createAccount` → settle) and returns the per-account report. Never logs or
260
+ * returns the password. Internal: admin key only.
261
+ */
262
+ export const provision = internalActionGeneric({
263
+ args: {},
264
+ handler: async (ctx): Promise<{ accounts: ProvisionedAccount[] }> => {
265
+ const password = readFixturePassword(process.env)
266
+ const accounts: ProvisionedAccount[] = []
267
+ for (const fixture of fixtureAccountList()) {
268
+ const prepared = await ctx.runMutation(prepareRef, { email: fixture.email, role: fixture.role })
269
+ let outcome: ProvisionedAccount['account'] = prepared.accountExists ? 'unchanged' : 'created'
270
+ try {
271
+ // The Password provider's own sign-up call — the account-creation gate runs inside it.
272
+ await createAccount(ctx, {
273
+ provider: PROVIDER,
274
+ account: { id: fixture.email, secret: password },
275
+ profile: { email: fixture.email },
276
+ })
277
+ } catch (error) {
278
+ // The library's "secret differs" signal on an EXISTING account (never on a new one).
279
+ const message = error instanceof Error ? error.message : String(error)
280
+ if (!prepared.accountExists || !/already exists/.test(message)) throw error
281
+ await modifyAccountCredentials(ctx, {
282
+ provider: PROVIDER,
283
+ account: { id: fixture.email, secret: password },
284
+ })
285
+ outcome = 'password-rotated'
286
+ }
287
+ const settled = await ctx.runMutation(settleRef, { email: fixture.email, role: fixture.role })
288
+ accounts.push({
289
+ ...fixture,
290
+ account: outcome,
291
+ membership: settled.membership,
292
+ invite: prepared.invite,
293
+ purgedOrphans: prepared.purgedOrphans,
294
+ })
295
+ }
296
+ return { accounts }
297
+ },
298
+ })
@@ -0,0 +1,149 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { convexTest } from 'convex-test'
3
+ import type { ConvexHttpClient } from 'convex/browser'
4
+ import type { FunctionReference } from 'convex/server'
5
+ import { fieldPath, measureMaxDepth } from '@elytracms/core/cms-core'
6
+ import { createChangesets, createOperations } from '@elytracms/core/operations'
7
+ import { ConvexPersistenceAdapter } from '../src/lib/persistence/convex-adapter'
8
+ import schema from './schema'
9
+
10
+ // convex-test fires a `runAfter(0)` on a REAL timer (the publish trigger's revalidation);
11
+ // fake timers keep it queued.
12
+ beforeEach(() => {
13
+ vi.useFakeTimers()
14
+ vi.setSystemTime('2026-10-28T00:00:00.000Z')
15
+ })
16
+ afterEach(() => {
17
+ vi.useRealTimers()
18
+ })
19
+
20
+ /**
21
+ * EC-669 — the Codex review's F21 counterexample on Convex (round 3, `races.convex.test.ts`),
22
+ * copied and INVERTED, plus the critic-on-plan's version cases: the real `cms:*` handlers,
23
+ * schema and triggers under convex-test, driven by the production operations over the
24
+ * production `ConvexPersistenceAdapter`. Deleting (`cms:deleteDocument`) or clearing
25
+ * (`cms:clearDocuments`) an identity records the revision and version it reached
26
+ * (`documentFloors`); a later create of the same `collection:id` starts above both, so a
27
+ * token the old lifetime minted names nothing in the new one. No wire change.
28
+ */
29
+
30
+ const modules = {
31
+ ...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
32
+ './_generated/api.ts': async () => ({}),
33
+ }
34
+
35
+ const projectId = 'incarnation'
36
+ const ref = { collection: 'post', id: 'p' }
37
+ const collections = () => [{ id: 'post', kind: 'document' as const, fields: [{ name: 'title', type: 'text' as const }] }]
38
+
39
+ async function world() {
40
+ const t = convexTest(schema, modules)
41
+ const stamp = '2026-10-28T00:00:00Z'
42
+ await t.run(async (ctx) => {
43
+ await ctx.db.insert('members', { userId: 'user_admin', role: 'admin', createdAt: stamp, updatedAt: stamp })
44
+ })
45
+ const admin = t.withIdentity({ subject: 'user_admin', issuer: 'https://convex.test' })
46
+ const client = {
47
+ query: (fn: FunctionReference<'query'>, args?: Record<string, unknown>) => admin.query(fn, args ?? {}),
48
+ mutation: (fn: FunctionReference<'mutation'>, args?: Record<string, unknown>) => admin.mutation(fn, args ?? {}),
49
+ } as unknown as ConvexHttpClient
50
+ const adapter = new ConvexPersistenceAdapter(client, projectId)
51
+ const options = { adapter, collections, validateDocument: () => [] }
52
+ const ops = createOperations(options)
53
+ const changesets = createChangesets(options)
54
+ const put = (title: string) =>
55
+ ops.documents.replace({ projectId, document: { ...ref, values: { title } }, precondition: { ifAbsent: true } })
56
+ return { t, adapter, ops, changesets, put }
57
+ }
58
+
59
+ it('F21 Convex: a recreated identity refuses a previous incarnation’s authoring revision', async () => {
60
+ const { adapter, ops, put } = await world()
61
+ expect((await put('old')).ok).toBe(true)
62
+ expect((await ops.documents.patch({ projectId, ref, baseRevision: 1, patches: [{ op: 'set', path: fieldPath('title'), value: 'B' }] })).ok).toBe(true)
63
+ const stale = () => ops.documents.patch({ projectId, ref, baseRevision: 1, patches: [{ op: 'set', path: fieldPath('title'), value: 'STALE' }] })
64
+ expect((await stale()).ok).toBe(false) // known-positive control (kept as written)
65
+ expect((await ops.documents.delete({ projectId, ref })).ok).toBe(true)
66
+ expect((await put('NEW')).ok).toBe(true)
67
+ // Inverted: the new lifetime starts above revision 2, and the stale patch refuses.
68
+ expect((await adapter.cms.getDocument(projectId, ref)).revision).toBe(3)
69
+ expect((await stale()).ok).toBe(false)
70
+ expect((await adapter.cms.getDocument(projectId, ref)).document.values.title).toBe('NEW')
71
+ })
72
+
73
+ describe('EC-669 Convex — revisions and versions are monotonic per identity across both removal mutations', () => {
74
+ it('control: a fresh identity still starts at revision 1 and version 1', async () => {
75
+ const { adapter, ops, put } = await world()
76
+ expect((await put('first')).ok).toBe(true)
77
+ expect((await adapter.cms.getDocument(projectId, ref)).revision).toBe(1)
78
+ expect((await ops.documents.publish({ projectId, ref })).ok).toBe(true)
79
+ expect((await adapter.cms.getDocument(projectId, ref)).publishedVersion).toBe(1)
80
+ })
81
+
82
+ for (const removal of ['delete', 'clear'] as const) {
83
+ it(`${removal}: stale revision and version tokens of the old lifetime refuse on the new one`, async () => {
84
+ const { adapter, ops, put } = await world()
85
+ expect((await put('old v1')).ok).toBe(true)
86
+ expect((await ops.documents.publish({ projectId, ref })).ok).toBe(true) // r2, v1
87
+ const removed = removal === 'delete' ? await ops.documents.delete({ projectId, ref }) : await ops.documents.clear({ projectId })
88
+ expect(removed.ok).toBe(true)
89
+ expect((await put('new')).ok).toBe(true)
90
+ expect((await adapter.cms.getDocument(projectId, ref)).revision).toBe(3)
91
+ expect((await ops.documents.publish({ projectId, ref })).ok).toBe(true)
92
+ const reborn = await adapter.cms.getDocument(projectId, ref)
93
+ expect(reborn.publishedVersion).toBe(2)
94
+ expect((await ops.documents.replace({ projectId, document: { ...ref, values: { title: 'STALE' } }, precondition: { revision: 1 } })).ok).toBe(false)
95
+ expect((await ops.documents.publish({ projectId, ref, expectedRevision: 2 })).ok).toBe(false)
96
+ expect((await ops.documents.delete({ projectId, ref, precondition: { revision: 2 } })).ok).toBe(false)
97
+ const pinned = await ops.documents.pinVersion({ projectId, ref, version: 1 })
98
+ expect(pinned.ok).toBe(false)
99
+ if (!pinned.ok) expect(pinned.error.code).toBe('not-found')
100
+ const restored = await ops.documents.restore({ projectId, ref, version: 1 })
101
+ expect(restored.ok).toBe(false)
102
+ if (!restored.ok) expect(restored.error.code).toBe('not-found')
103
+ const after = await adapter.cms.getDocument(projectId, ref)
104
+ expect(after.revision).toBe(reborn.revision)
105
+ expect(after.publishedVersion).toBe(2)
106
+ expect(after.document.values.title).toBe('new')
107
+ })
108
+ }
109
+
110
+ it('the floor holds across repeated lifetimes, and a second removal raises it', async () => {
111
+ const { t, adapter, ops, put } = await world()
112
+ expect((await put('one')).ok).toBe(true)
113
+ expect((await ops.documents.publish({ projectId, ref })).ok).toBe(true) // r2, v1
114
+ expect((await ops.documents.delete({ projectId, ref })).ok).toBe(true)
115
+ expect((await put('two')).ok).toBe(true) // r3
116
+ expect((await ops.documents.publish({ projectId, ref })).ok).toBe(true) // r4, v2
117
+ expect((await ops.documents.clear({ projectId })).ok).toBe(true)
118
+ expect((await put('three')).ok).toBe(true)
119
+ expect((await adapter.cms.getDocument(projectId, ref)).revision).toBe(5)
120
+ expect((await ops.documents.publish({ projectId, ref })).ok).toBe(true)
121
+ expect((await adapter.cms.getDocument(projectId, ref)).publishedVersion).toBe(3)
122
+ // One floor row per identity, raised in place (never a row per removal).
123
+ const floors = await t.run((ctx) => ctx.db.query('documentFloors').collect())
124
+ expect(floors.map(({ collection, docId, revision, version }) => ({ collection, docId, revision, version }))).toEqual([
125
+ { collection: 'post', docId: 'p', revision: 4, version: 2 },
126
+ ])
127
+ // A new stored shape stays inside Convex's 16-level cap (flat scalars).
128
+ expect(measureMaxDepth(floors[0]).depth).toBeLessThanOrEqual(16)
129
+ })
130
+
131
+ it('a changeset that re-creates a removed identity records a REMOVE inverse, and its revert removes it', async () => {
132
+ const { adapter, ops, changesets, put } = await world()
133
+ expect((await put('old')).ok).toBe(true)
134
+ expect((await ops.documents.delete({ projectId, ref })).ok).toBe(true)
135
+ const applied = await changesets.apply(
136
+ changesets.create({
137
+ title: 're-create',
138
+ steps: [{ operation: 'documents.replace', input: { projectId, document: { ...ref, values: { title: 'again' } }, precondition: { ifAbsent: true } } }],
139
+ }),
140
+ )
141
+ expect(applied.ok).toBe(true)
142
+ if (!applied.ok) throw new Error(applied.error.message)
143
+ expect(applied.value.receipts).toEqual([
144
+ expect.objectContaining({ beforeRevision: null, afterRevision: 2, inverse: { kind: 'remove' } }),
145
+ ])
146
+ expect((await changesets.revert(applied.value)).ok).toBe(true)
147
+ expect(await adapter.cms.findDocument(projectId, ref)).toBeUndefined()
148
+ })
149
+ })
@@ -0,0 +1,161 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { convexTest } from 'convex-test'
3
+ import { makeFunctionReference } from 'convex/server'
4
+ import { v } from 'convex/values'
5
+ import { z } from 'zod'
6
+ import { INGEST_RECEIVER_PATH, signedIngestRequest } from '@elytracms/core/ingest'
7
+ import type { IngestHandlerResult } from '@elytracms/core/ingest'
8
+ import { defineRecordKind } from '@elytracms/core/persistence'
9
+ import schema from './schema'
10
+ import { internalMutation } from './functions'
11
+ import { defineStudioHttp } from './http'
12
+ import { INGEST_HANDLER_ATTEMPTS, ingestHandlerArgs, parseIngestPayload } from './ingest'
13
+ import type { IngestHandlerRef } from './ingest'
14
+ import { saveRecord } from './record-functions'
15
+
16
+ /**
17
+ * EC-618 (the ratified low-volume lever, the critic's correction 1) — the receiver RETRIES a
18
+ * handler whose mutation THREW, with an additive, optional `attempt` argument. A Convex mutation
19
+ * cannot observe its own optimistic-concurrency conflict: Convex re-executes it, and exhaustion
20
+ * surfaces only to the caller — the receiver's `ctx.runMutation` (measured on the dev deployment
21
+ * in wave-0095: every receiver 500 of the burst was an `analytics:ingest` execution that failed
22
+ * after its retries). So the receiver is where a handler gets its next attempt; analytics uses
23
+ * it to open an overflow shard, every other handler ignores it.
24
+ *
25
+ * convex-test SERIALISES mutations, so no write conflict can happen here: the failure is
26
+ * INJECTED (a handler that writes, then throws on its early attempts). What this proves is the
27
+ * receiver's contract — attempt 1 carries no `attempt` key, a throw is retried with `attempt: n`,
28
+ * a thrown attempt committed nothing, a refusal is final, the attempts are bounded — never
29
+ * Convex's OCC (that is the dev-deployment burst, `pnpm dev:ingest rosy-armadillo-302 ec618-burst`).
30
+ */
31
+
32
+ const SECRET = 'ec618-attempt-secret'
33
+ const env = () => (globalThis as { process: { env: Record<string, string | undefined> } }).process.env
34
+
35
+ const hitKind = defineRecordKind({
36
+ kind: 'testHit',
37
+ module: 'testAttempt',
38
+ keys: ['label'],
39
+ fields: { label: z.string().min(1), attempt: z.number().int().positive() },
40
+ })
41
+
42
+ /** Every execution the handlers saw, with the `attempt` they were given (`undefined` = the key was absent). */
43
+ let seen: { handler: string; attempt: number | undefined; keys: string[] }[] = []
44
+ /** The flaky handler throws on every attempt below this one (after writing — the write must not commit). */
45
+ let succeedFrom = 1
46
+
47
+ async function writeHit(ctx: Parameters<typeof saveRecord>[0], label: string, attempt: number, receivedAt: string) {
48
+ const id = `hit_${(await ctx.db.query('records').collect()).length + 1}`
49
+ await saveRecord(ctx, hitKind, id, { label, attempt }, { createdAt: receivedAt, updatedAt: receivedAt })
50
+ return id
51
+ }
52
+
53
+ const testAttemptModule = {
54
+ /** Writes its hit, then throws while `attempt < succeedFrom` — an exhausted write conflict, injected. */
55
+ flaky: internalMutation({
56
+ args: ingestHandlerArgs,
57
+ handler: async (ctx, args): Promise<IngestHandlerResult> => {
58
+ seen.push({ handler: 'flaky', attempt: args.attempt, keys: Object.keys(args).sort() })
59
+ const payload = parseIngestPayload(args.payload)
60
+ const attempt = args.attempt ?? 1
61
+ const id = await writeHit(ctx, payload.kind === 'event' ? String(payload.data['name']) : payload.kind, attempt, args.receivedAt)
62
+ if (attempt < succeedFrom) throw new Error(`injected: the write conflicted on every retry (attempt ${attempt})`)
63
+ return { ok: true, id }
64
+ },
65
+ }),
66
+ /** A handler declared with the PRE-attempt arguments (strict: an extra key would be refused). */
67
+ legacy: internalMutation({
68
+ args: { payload: v.any(), receivedAt: v.string() },
69
+ handler: async (ctx, args): Promise<IngestHandlerResult> => {
70
+ seen.push({ handler: 'legacy', attempt: undefined, keys: Object.keys(args).sort() })
71
+ return { ok: true, id: await writeHit(ctx, 'legacy', 1, args.receivedAt) }
72
+ },
73
+ }),
74
+ /** A refusal is an ANSWER, not a failure: never retried. */
75
+ refusing: internalMutation({
76
+ args: ingestHandlerArgs,
77
+ handler: async (_ctx, args): Promise<IngestHandlerResult> => {
78
+ seen.push({ handler: 'refusing', attempt: args.attempt, keys: Object.keys(args).sort() })
79
+ return { ok: false, status: 409, message: 'Refused on purpose.' }
80
+ },
81
+ }),
82
+ }
83
+
84
+ const ref = (name: string) => makeFunctionReference<'mutation'>(`testAttempt:${name}`) as unknown as IngestHandlerRef
85
+ const silentAuth = { addHttpRoutes: () => {} }
86
+
87
+ function backend(event: IngestHandlerRef) {
88
+ return convexTest(schema, {
89
+ ...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
90
+ './_generated/api.ts': async () => ({}),
91
+ './testAttempt.ts': async () => testAttemptModule,
92
+ './http.ts': async () => ({ default: defineStudioHttp({ auth: silentAuth, ingest: { event } }) }),
93
+ })
94
+ }
95
+
96
+ async function post(t: ReturnType<typeof backend>, name = 'pageview') {
97
+ const signed = await signedIngestRequest({ kind: 'event', data: { name } }, SECRET)
98
+ return t.fetch(INGEST_RECEIVER_PATH, { method: 'POST', headers: signed.headers, body: signed.body })
99
+ }
100
+
101
+ async function hits(t: ReturnType<typeof backend>) {
102
+ return t.run(async (ctx) =>
103
+ (await ctx.db.query('records').collect()).filter((row) => row.kind === hitKind.kind).map((row) => row.data as { label: string; attempt: number }),
104
+ )
105
+ }
106
+
107
+ beforeEach(() => {
108
+ vi.useFakeTimers()
109
+ vi.setSystemTime(new Date('2026-10-28T12:00:00.000Z'))
110
+ env()['ELYTRA_INGEST_SECRET'] = SECRET
111
+ seen = []
112
+ succeedFrom = 1
113
+ })
114
+ afterEach(() => {
115
+ vi.useRealTimers()
116
+ delete env()['ELYTRA_INGEST_SECRET']
117
+ })
118
+
119
+ describe('the receiver retries a handler that THREW, with an additive `attempt` (EC-618)', () => {
120
+ it('attempt 1 carries no `attempt` key — a handler declared with the pre-attempt arguments runs unchanged', async () => {
121
+ const t = backend(ref('legacy'))
122
+ expect((await post(t)).status).toBe(200)
123
+ expect(seen).toEqual([{ handler: 'legacy', attempt: undefined, keys: ['payload', 'receivedAt'] }])
124
+ expect(await hits(t)).toEqual([{ label: 'legacy', attempt: 1 }])
125
+ })
126
+
127
+ it('a throw is retried with `attempt: 2`; the thrown attempt committed nothing (no double count)', async () => {
128
+ succeedFrom = 2
129
+ const t = backend(ref('flaky'))
130
+ const response = await post(t)
131
+ expect(response.status).toBe(200)
132
+ expect(seen.map((entry) => entry.attempt)).toEqual([undefined, 2])
133
+ expect(seen[1]!.keys).toEqual(['attempt', 'payload', 'receivedAt'])
134
+ // Attempt 1 wrote its hit and threw: rolled back. Only attempt 2's write is stored.
135
+ expect(await hits(t)).toEqual([{ label: 'pageview', attempt: 2 }])
136
+ })
137
+
138
+ it('the attempts are bounded: a handler that throws every time is run INGEST_HANDLER_ATTEMPTS times, then the failure surfaces', async () => {
139
+ succeedFrom = Number.POSITIVE_INFINITY
140
+ const t = backend(ref('flaky'))
141
+ // convex-test rethrows a throwing httpAction to the caller; a deployment answers 500.
142
+ await expect(post(t)).rejects.toThrow(/injected: the write conflicted on every retry \(attempt 3\)/)
143
+ expect(INGEST_HANDLER_ATTEMPTS).toBe(3)
144
+ expect(seen.map((entry) => entry.attempt)).toEqual([undefined, 2, 3])
145
+ expect(await hits(t)).toEqual([]) // nothing committed by any of them
146
+ })
147
+
148
+ it('a refusal is final — relayed as it was, never retried', async () => {
149
+ const t = backend(ref('refusing'))
150
+ const response = await post(t)
151
+ expect(response.status).toBe(409)
152
+ expect(seen).toHaveLength(1)
153
+ })
154
+
155
+ it('control — without a failure every request runs its handler exactly once (the instrument can count one)', async () => {
156
+ const t = backend(ref('flaky'))
157
+ for (let i = 0; i < 5; i += 1) expect((await post(t, `e${i}`)).status).toBe(200)
158
+ expect(seen.map((entry) => entry.attempt)).toEqual([undefined, undefined, undefined, undefined, undefined])
159
+ expect((await hits(t)).map((hit) => hit.attempt)).toEqual([1, 1, 1, 1, 1])
160
+ })
161
+ })