@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.
- package/convex/assets.ts +16 -2
- package/convex/cms.ts +153 -13
- package/convex/delivery-folded-drift.test.ts +104 -0
- package/convex/delivery.ts +24 -8
- package/convex/depth-codec-lookalike.test.ts +192 -0
- package/convex/fixture-accounts.test.ts +305 -0
- package/convex/fixtureAccounts.ts +298 -0
- package/convex/incarnation.test.ts +149 -0
- package/convex/ingest-attempt.test.ts +161 -0
- package/convex/ingest.test.ts +327 -9
- package/convex/ingest.ts +242 -24
- package/convex/prototype-key.test.ts +92 -0
- package/convex/receipt-races.test.ts +184 -0
- package/convex/records-zod-copy.test.ts +76 -0
- package/convex/records-zod-refinements.convex.test.ts +94 -0
- package/convex/schema.ts +20 -1
- package/convex/strict-delete.test.ts +129 -0
- package/dist/components/cms/cms-store.d.ts +6 -5
- package/dist/components/cms/cms-store.js +14 -34
- package/dist/components/cms/cms-store.js.map +1 -1
- package/dist/components/cms/field-control.d.ts +12 -1
- package/dist/components/cms/field-control.js +16 -1
- package/dist/components/cms/field-control.js.map +1 -1
- package/dist/components/cms/import-batch.d.ts +23 -0
- package/dist/components/cms/import-batch.js +43 -0
- package/dist/components/cms/import-batch.js.map +1 -0
- package/dist/components/cms/rich-text/editor.js +13 -10
- package/dist/components/cms/rich-text/editor.js.map +1 -1
- package/dist/components/cms/rich-text/value.d.ts +9 -0
- package/dist/components/cms/rich-text/value.js +13 -0
- package/dist/components/cms/rich-text/value.js.map +1 -1
- package/dist/components/cms/use-document-draft.d.ts +76 -4
- package/dist/components/cms/use-document-draft.js +94 -25
- package/dist/components/cms/use-document-draft.js.map +1 -1
- package/dist/components/records/record-lens.js.map +1 -1
- package/dist/lib/collection-view/collection-view-source.d.ts +5 -2
- package/dist/lib/collection-view/collection-view-source.js.map +1 -1
- package/dist/lib/engine-version.d.ts +1 -1
- package/dist/lib/engine-version.js +1 -1
- package/dist/lib/engine-version.js.map +1 -1
- package/dist/lib/i18n/messages.de.js +5 -0
- package/dist/lib/i18n/messages.de.js.map +1 -1
- package/dist/lib/i18n/messages.en.d.ts +5 -0
- package/dist/lib/i18n/messages.en.js +5 -0
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/operations/cms-persistence.d.ts +49 -5
- package/dist/lib/operations/cms-persistence.js +237 -47
- package/dist/lib/operations/cms-persistence.js.map +1 -1
- package/dist/lib/persistence/convex-adapter.js +27 -1
- package/dist/lib/persistence/convex-adapter.js.map +1 -1
- package/dist/lib/persona/labels.js +2 -0
- package/dist/lib/persona/labels.js.map +1 -1
- 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
|
+
})
|