@beechcms/api 0.6.0-preview.1 → 0.6.0-preview.2
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/assets/dashboard/BeechLogo.svg +18 -18
- package/assets/dashboard/BeechLogoLIght.svg +48 -48
- package/assets/dashboard/assets/chart-BrJFdsGZ.js +39 -0
- package/assets/dashboard/assets/index-CIaoMxuB.css +1 -0
- package/assets/dashboard/assets/index-wTKgygsg.js +636 -0
- package/assets/dashboard/assets/pie-chart-recharts-BEaE1FOE.js +1 -0
- package/assets/dashboard/assets/timeseries-chart-recharts-DmWM6imF.js +1 -0
- package/assets/dashboard/beechLogoDark.svg +48 -48
- package/assets/dashboard/index.html +18 -18
- package/assets/dashboard/noResult.svg +42 -42
- package/assets/dashboard/sol.svg +3 -3
- package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
- package/migrations/0000_v040_base.sql +265 -265
- package/migrations/0029_automations.sql +14 -14
- package/migrations/0030_test_seeds.sql +125 -125
- package/package.json +2 -2
- package/src/auth/__fixtures__/in-memory-hash-provider.ts +17 -17
- package/src/auth/__fixtures__/static-token-service.ts +22 -22
- package/src/auth/bcrypt-hash-provider.ts +24 -24
- package/src/auth/constants.ts +14 -14
- package/src/auth/generate-refresh-token.test.ts +23 -23
- package/src/auth/hash-provider.test.ts +55 -55
- package/src/auth/jose-token-service.ts +62 -62
- package/src/auth/jwt-claims-passthrough.test.ts +87 -87
- package/src/auth/login.test.ts +100 -100
- package/src/auth/login.ts +78 -78
- package/src/auth/refresh.ts +11 -11
- package/src/auth/token-service.test.ts +90 -90
- package/src/factory.ts +370 -368
- package/src/features/automations/__tests__/action-executors.test.ts +255 -255
- package/src/features/automations/__tests__/automation-runner.test.ts +196 -196
- package/src/features/automations/__tests__/automation-runner.utils.test.ts +60 -60
- package/src/features/automations/__tests__/automations.handler.test.ts +264 -264
- package/src/features/automations/__tests__/automations.repository.test.ts +163 -163
- package/src/features/automations/__tests__/automations.schema.test.ts +224 -224
- package/src/features/automations/__tests__/context-resolver.test.ts +126 -126
- package/src/features/automations/__tests__/cron-runner.test.ts +267 -267
- package/src/features/automations/__tests__/cron-runner.utils.test.ts +144 -144
- package/src/features/automations/__tests__/set-variable.executor.test.ts +383 -383
- package/src/features/automations/__tests__/template-grammar.test.ts +308 -308
- package/src/features/automations/__tests__/webhook.executor.test.ts +142 -142
- package/src/features/automations/__tests__/when-evaluator.test.ts +274 -274
- package/src/features/automations/__tests__/when-pushdown.test.ts +281 -281
- package/src/features/automations/action-executors/create-entry.executor.ts +27 -27
- package/src/features/automations/action-executors/edit-field.executor.ts +26 -26
- package/src/features/automations/action-executors/index.ts +37 -37
- package/src/features/automations/action-executors/send-mail.executor.ts +53 -53
- package/src/features/automations/action-executors/set-variable.executor.ts +159 -159
- package/src/features/automations/action-executors/webhook.executor.ts +67 -67
- package/src/features/automations/automation-runner.ts +85 -85
- package/src/features/automations/automation-runner.utils.ts +47 -47
- package/src/features/automations/automations.handler.ts +197 -197
- package/src/features/automations/automations.schema.ts +177 -177
- package/src/features/automations/context-resolver.ts +152 -152
- package/src/features/automations/cron-runner.ts +140 -140
- package/src/features/automations/cron-runner.utils.ts +44 -44
- package/src/features/automations/filter-translation.ts +46 -46
- package/src/features/automations/index.ts +16 -16
- package/src/features/automations/template-grammar.ts +245 -245
- package/src/features/automations/var-access-resolver.ts +140 -140
- package/src/features/automations/when-evaluator.ts +87 -87
- package/src/features/automations/when-pushdown.ts +57 -57
- package/src/features/backrefs/__tests__/backrefs.handler.test.ts +359 -359
- package/src/features/backrefs/backrefs.handler.ts +168 -168
- package/src/features/backrefs/d1-backref.repository.ts +84 -84
- package/src/features/backrefs/index.ts +5 -5
- package/src/features/content/constants.ts +16 -16
- package/src/features/content/handlers/bulk.handler.ts +185 -185
- package/src/features/content/handlers/create.ts +129 -129
- package/src/features/content/handlers/delete.ts +62 -62
- package/src/features/content/handlers/facets.ts +49 -49
- package/src/features/content/handlers/get.ts +120 -120
- package/src/features/content/handlers/helpers.test.ts +180 -180
- package/src/features/content/handlers/helpers.ts +93 -93
- package/src/features/content/handlers/list.ts +174 -174
- package/src/features/content/handlers/update.ts +174 -174
- package/src/features/content/index.ts +26 -26
- package/src/features/dashboard-layout/__tests__/dashboard-layout.handler.test.ts +388 -0
- package/src/features/dashboard-layout/dashboard-layout.handler.ts +206 -0
- package/src/features/dashboard-layout/index.ts +5 -0
- package/src/features/draft/draft.handler.ts +195 -195
- package/src/features/draft/draft.middleware.ts +66 -66
- package/src/features/draft/index.ts +5 -5
- package/src/features/email/email.provider.ts +42 -42
- package/src/features/email/email.service.ts +98 -98
- package/src/features/email/email.types.ts +119 -119
- package/src/features/email/index.ts +33 -33
- package/src/features/email/providers/resend.ts +67 -67
- package/src/features/email/providers/smtp.ts +55 -55
- package/src/features/email/templates/automation-mail.ts +19 -19
- package/src/features/email/templates/password-changed.ts +63 -63
- package/src/features/email/templates/password-reset.ts +68 -68
- package/src/features/email/templates/shell.ts +96 -96
- package/src/features/notifications/index.ts +5 -5
- package/src/features/notifications/notifications.handler.ts +105 -105
- package/src/features/password-reset/index.ts +19 -19
- package/src/features/password-reset/request.ts +92 -92
- package/src/features/password-reset/reset.ts +102 -102
- package/src/features/rotate-field/index.ts +5 -5
- package/src/features/rotate-field/rotate-field.handler.ts +132 -132
- package/src/features/rotate-field/rotate-field.schema.ts +17 -17
- package/src/features/schema/schema.handler.ts +131 -131
- package/src/features/seeds/index.ts +5 -5
- package/src/features/seeds/seeds.handler.test.ts +1199 -632
- package/src/features/seeds/seeds.handler.ts +558 -693
- package/src/features/settings/__tests__/settings.handler.test.ts +555 -555
- package/src/features/settings/settings.handler.ts +392 -392
- package/src/features/setup/index.ts +288 -204
- package/src/features/stats/index.ts +5 -5
- package/src/features/stats/stats.handler.ts +420 -420
- package/src/features/webhooks/index.ts +63 -63
- package/src/index.ts +65 -65
- package/src/media-utils.ts +82 -82
- package/src/middleware/auth-providers.middleware.test.ts +85 -0
- package/src/middleware/auth-providers.middleware.ts +45 -45
- package/src/middleware/observability.middleware.test.ts +93 -0
- package/src/middleware/observability.middleware.ts +70 -70
- package/src/middleware/rate-limit.middleware.ts +45 -45
- package/src/middleware/repository.middleware.ts +117 -114
- package/src/middleware/seed-registry.middleware.test.ts +97 -97
- package/src/middleware/seed-registry.middleware.ts +21 -21
- package/src/middleware/storage.middleware.ts +25 -25
- package/src/middleware.ts +45 -45
- package/src/public/access-policy.ts +27 -27
- package/src/public/api-key-middleware.ts +57 -57
- package/src/public/cache-utils.ts +38 -38
- package/src/public/entry-projection.ts +46 -46
- package/src/public/idempotency.ts +23 -23
- package/src/public/index.ts +16 -16
- package/src/public/problem-details.ts +124 -124
- package/src/public/public-add.ts +114 -114
- package/src/public/public-edit.ts +163 -163
- package/src/public/public-errors.ts +19 -19
- package/src/public/public-read.ts +63 -63
- package/src/public/public-routes.ts +88 -88
- package/src/public/query-builder.test.ts +224 -224
- package/src/public/query-builder.ts +156 -156
- package/src/public/rate-limit-middleware.ts +34 -34
- package/src/public/read-list.ts +54 -54
- package/src/public/read-single.ts +48 -48
- package/src/public/response-builder.ts +30 -30
- package/src/public/sanitize.ts +69 -69
- package/src/public/slug-utils.ts +18 -18
- package/src/rate-limit/cloudflare-rate-limiter.test.ts +30 -30
- package/src/rate-limit/cloudflare-rate-limiter.ts +15 -15
- package/src/rate-limit/in-memory-rate-limiter.test.ts +37 -37
- package/src/rate-limit/in-memory-rate-limiter.ts +17 -17
- package/src/rate-limit/no-op-rate-limiter.test.ts +22 -22
- package/src/rate-limit/no-op-rate-limiter.ts +11 -11
- package/src/search-utils.test.ts +211 -211
- package/src/search-utils.ts +213 -213
- package/src/search.ts +65 -65
- package/src/shared/apply-policies.test.ts +81 -81
- package/src/shared/apply-policies.ts +67 -67
- package/src/shared/automations.repository.d1.ts +150 -150
- package/src/shared/background-notification-service.test.ts +62 -62
- package/src/shared/background-notification-service.ts +52 -52
- package/src/shared/base.repository.d1.ts +32 -32
- package/src/shared/content-utils.test.ts +165 -165
- package/src/shared/content-utils.ts +86 -86
- package/src/shared/content.repository.d1.test.ts +507 -458
- package/src/shared/content.repository.d1.ts +851 -851
- package/src/shared/d1-activity-log.repository.test.ts +140 -140
- package/src/shared/d1-activity-log.repository.ts +105 -105
- package/src/shared/d1-activity-logger.test.ts +86 -86
- package/src/shared/d1-activity-logger.ts +67 -67
- package/src/shared/d1-analytics.repository.test.ts +78 -78
- package/src/shared/d1-analytics.repository.ts +85 -85
- package/src/shared/d1-content-scan.repository.test.ts +80 -80
- package/src/shared/d1-content-scan.repository.ts +37 -37
- package/src/shared/d1-notification.repository.test.ts +128 -128
- package/src/shared/d1-notification.repository.ts +118 -118
- package/src/shared/d1-password-reset-token.repository.test.ts +81 -81
- package/src/shared/d1-password-reset-token.repository.ts +56 -56
- package/src/shared/d1-search.repository.test.ts +87 -87
- package/src/shared/d1-search.repository.ts +88 -88
- package/src/shared/d1-session.repository.test.ts +125 -125
- package/src/shared/d1-session.repository.ts +102 -102
- package/src/shared/d1-setup-checklist.repository.ts +36 -36
- package/src/shared/d1-user.repository.test.ts +151 -151
- package/src/shared/d1-user.repository.ts +119 -119
- package/src/shared/d1-widget.repository.test.ts +267 -221
- package/src/shared/d1-widget.repository.ts +385 -341
- package/src/shared/dashboard-layout.repository.d1.test.ts +135 -0
- package/src/shared/dashboard-layout.repository.d1.ts +58 -0
- package/src/shared/demo-data-sql.ts +216 -54
- package/src/shared/demo-data.repository.d1.ts +20 -20
- package/src/shared/execution-context-scheduler.ts +13 -13
- package/src/shared/fixed-clock.ts +25 -25
- package/src/shared/fts-sync.ts +8 -8
- package/src/shared/idempotency.repository.d1.test.ts +83 -83
- package/src/shared/idempotency.repository.d1.ts +60 -60
- package/src/shared/in-memory-activity-logger.ts +19 -19
- package/src/shared/in-memory-notification-service.ts +19 -19
- package/src/shared/in-memory-seed.repository.ts +58 -51
- package/src/shared/media.repository.d1.test.ts +107 -107
- package/src/shared/media.repository.d1.ts +68 -68
- package/src/shared/qstash-notification-service.test.ts +67 -67
- package/src/shared/qstash-notification-service.ts +55 -55
- package/src/shared/query-utils.ts +141 -141
- package/src/shared/request-utils.ts +26 -26
- package/src/shared/schema-mutator.d1.test.ts +142 -0
- package/src/shared/schema-mutator.d1.ts +64 -64
- package/src/shared/seed-layout.repository.d1.test.ts +114 -114
- package/src/shared/seed-layout.repository.d1.ts +62 -62
- package/src/shared/seed-registry-cache.test.ts +68 -68
- package/src/shared/seed-registry-cache.ts +49 -49
- package/src/shared/seed.repository.d1.test.ts +143 -143
- package/src/shared/seed.repository.d1.ts +98 -98
- package/src/shared/sequential-id-generator.ts +33 -33
- package/src/shared/site-settings.repository.d1.ts +53 -53
- package/src/shared/storage/factory.ts +41 -41
- package/src/shared/storage/s3-bucket.ts +195 -195
- package/src/shared/storage-utils.ts +40 -40
- package/src/shared/system-stats.repository.d1.test.ts +58 -58
- package/src/shared/system-stats.repository.d1.ts +48 -48
- package/src/types.ts +90 -87
- package/src/upload.ts +198 -198
- package/src/widget.test.ts +85 -0
- package/src/widget.ts +251 -212
- package/assets/dashboard/assets/index-BSjk0Sx9.js +0 -634
- package/assets/dashboard/assets/index-kRCYVN2B.css +0 -1
|
@@ -1,81 +1,81 @@
|
|
|
1
|
-
// SPDX-License-Identifier: BUSL-1.1
|
|
2
|
-
// Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
|
|
3
|
-
// See LICENSE in the repository root for license terms.
|
|
4
|
-
|
|
5
|
-
import { describe, it, expect, vi } from 'vitest'
|
|
6
|
-
import { D1PasswordResetTokenRepository } from './d1-password-reset-token.repository'
|
|
7
|
-
|
|
8
|
-
const NOW = Math.floor(Date.now() / 1000)
|
|
9
|
-
const fixedIdGen = { uuid: () => 'prt-1' }
|
|
10
|
-
|
|
11
|
-
function makeMockDb(opts: { firstResult?: unknown; runChanges?: number } = {}) {
|
|
12
|
-
const { firstResult = null, runChanges = 1 } = opts
|
|
13
|
-
const runMock = vi.fn().mockResolvedValue({ success: true, meta: { changes: runChanges } })
|
|
14
|
-
const firstMock = vi.fn().mockResolvedValue(firstResult)
|
|
15
|
-
const bindMock = vi.fn(() => ({ first: firstMock, run: runMock }))
|
|
16
|
-
const prepareMock = vi.fn(() => ({ bind: bindMock }))
|
|
17
|
-
return { db: { prepare: prepareMock } as any, prepareMock, bindMock, runMock }
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
describe('D1PasswordResetTokenRepository', () => {
|
|
21
|
-
describe('create', () => {
|
|
22
|
-
it('calls INSERT INTO password_reset_tokens', async () => {
|
|
23
|
-
const { db, prepareMock } = makeMockDb()
|
|
24
|
-
await new D1PasswordResetTokenRepository(db, fixedIdGen).create({
|
|
25
|
-
userId: 'u1', tokenHash: 'h', expiresAt: NOW + 1800,
|
|
26
|
-
})
|
|
27
|
-
expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO password_reset_tokens'))
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
it('binds id, userId, tokenHash, and expiresAt in the correct order', async () => {
|
|
31
|
-
const { db, bindMock } = makeMockDb()
|
|
32
|
-
await new D1PasswordResetTokenRepository(db, fixedIdGen).create({
|
|
33
|
-
userId: 'u1', tokenHash: 'h', expiresAt: NOW + 1800,
|
|
34
|
-
})
|
|
35
|
-
expect(bindMock).toHaveBeenCalledWith('prt-1', 'u1', 'h', NOW + 1800)
|
|
36
|
-
})
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
describe('findValidByHashWithEmail', () => {
|
|
40
|
-
it('returns a ValidatedResetToken with the userId and email from the JOIN', async () => {
|
|
41
|
-
const row = { id: 'prt-1', user_id: 'u1', email: 'user@test.com' }
|
|
42
|
-
const { db } = makeMockDb({ firstResult: row })
|
|
43
|
-
const result = await new D1PasswordResetTokenRepository(db, fixedIdGen).findValidByHashWithEmail('h', NOW)
|
|
44
|
-
expect(result).toEqual({ id: 'prt-1', userId: 'u1', email: 'user@test.com' })
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
it('returns null when the token is not found, expired, or already used', async () => {
|
|
48
|
-
const { db } = makeMockDb({ firstResult: null })
|
|
49
|
-
expect(await new D1PasswordResetTokenRepository(db, fixedIdGen).findValidByHashWithEmail('bad-hash', NOW)).toBeNull()
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
it('passes tokenHash and nowTimestamp as bound values', async () => {
|
|
53
|
-
const { db, bindMock } = makeMockDb()
|
|
54
|
-
await new D1PasswordResetTokenRepository(db, fixedIdGen).findValidByHashWithEmail('my-hash', NOW)
|
|
55
|
-
expect(bindMock).toHaveBeenCalledWith('my-hash', NOW)
|
|
56
|
-
})
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
describe('markUsed', () => {
|
|
60
|
-
it('calls UPDATE password_reset_tokens SET used_at', async () => {
|
|
61
|
-
const { db, prepareMock } = makeMockDb()
|
|
62
|
-
await new D1PasswordResetTokenRepository(db, fixedIdGen).markUsed('prt-1', NOW)
|
|
63
|
-
expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('used_at'))
|
|
64
|
-
})
|
|
65
|
-
|
|
66
|
-
it('binds nowTimestamp and tokenId in the correct order', async () => {
|
|
67
|
-
const { db, bindMock } = makeMockDb()
|
|
68
|
-
await new D1PasswordResetTokenRepository(db, fixedIdGen).markUsed('prt-1', NOW)
|
|
69
|
-
expect(bindMock).toHaveBeenCalledWith(NOW, 'prt-1')
|
|
70
|
-
})
|
|
71
|
-
})
|
|
72
|
-
|
|
73
|
-
describe('invalidatePending', () => {
|
|
74
|
-
it('calls UPDATE password_reset_tokens and includes the userId in the bound values', async () => {
|
|
75
|
-
const { db, prepareMock, bindMock } = makeMockDb()
|
|
76
|
-
await new D1PasswordResetTokenRepository(db, fixedIdGen).invalidatePending('u1', NOW)
|
|
77
|
-
expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('UPDATE password_reset_tokens'))
|
|
78
|
-
expect((bindMock.mock.calls[0] as unknown[]).includes('u1')).toBe(true)
|
|
79
|
-
})
|
|
80
|
-
})
|
|
81
|
-
})
|
|
1
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
2
|
+
// Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
|
|
3
|
+
// See LICENSE in the repository root for license terms.
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
6
|
+
import { D1PasswordResetTokenRepository } from './d1-password-reset-token.repository'
|
|
7
|
+
|
|
8
|
+
const NOW = Math.floor(Date.now() / 1000)
|
|
9
|
+
const fixedIdGen = { uuid: () => 'prt-1' }
|
|
10
|
+
|
|
11
|
+
function makeMockDb(opts: { firstResult?: unknown; runChanges?: number } = {}) {
|
|
12
|
+
const { firstResult = null, runChanges = 1 } = opts
|
|
13
|
+
const runMock = vi.fn().mockResolvedValue({ success: true, meta: { changes: runChanges } })
|
|
14
|
+
const firstMock = vi.fn().mockResolvedValue(firstResult)
|
|
15
|
+
const bindMock = vi.fn(() => ({ first: firstMock, run: runMock }))
|
|
16
|
+
const prepareMock = vi.fn(() => ({ bind: bindMock }))
|
|
17
|
+
return { db: { prepare: prepareMock } as any, prepareMock, bindMock, runMock }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('D1PasswordResetTokenRepository', () => {
|
|
21
|
+
describe('create', () => {
|
|
22
|
+
it('calls INSERT INTO password_reset_tokens', async () => {
|
|
23
|
+
const { db, prepareMock } = makeMockDb()
|
|
24
|
+
await new D1PasswordResetTokenRepository(db, fixedIdGen).create({
|
|
25
|
+
userId: 'u1', tokenHash: 'h', expiresAt: NOW + 1800,
|
|
26
|
+
})
|
|
27
|
+
expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO password_reset_tokens'))
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('binds id, userId, tokenHash, and expiresAt in the correct order', async () => {
|
|
31
|
+
const { db, bindMock } = makeMockDb()
|
|
32
|
+
await new D1PasswordResetTokenRepository(db, fixedIdGen).create({
|
|
33
|
+
userId: 'u1', tokenHash: 'h', expiresAt: NOW + 1800,
|
|
34
|
+
})
|
|
35
|
+
expect(bindMock).toHaveBeenCalledWith('prt-1', 'u1', 'h', NOW + 1800)
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe('findValidByHashWithEmail', () => {
|
|
40
|
+
it('returns a ValidatedResetToken with the userId and email from the JOIN', async () => {
|
|
41
|
+
const row = { id: 'prt-1', user_id: 'u1', email: 'user@test.com' }
|
|
42
|
+
const { db } = makeMockDb({ firstResult: row })
|
|
43
|
+
const result = await new D1PasswordResetTokenRepository(db, fixedIdGen).findValidByHashWithEmail('h', NOW)
|
|
44
|
+
expect(result).toEqual({ id: 'prt-1', userId: 'u1', email: 'user@test.com' })
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('returns null when the token is not found, expired, or already used', async () => {
|
|
48
|
+
const { db } = makeMockDb({ firstResult: null })
|
|
49
|
+
expect(await new D1PasswordResetTokenRepository(db, fixedIdGen).findValidByHashWithEmail('bad-hash', NOW)).toBeNull()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('passes tokenHash and nowTimestamp as bound values', async () => {
|
|
53
|
+
const { db, bindMock } = makeMockDb()
|
|
54
|
+
await new D1PasswordResetTokenRepository(db, fixedIdGen).findValidByHashWithEmail('my-hash', NOW)
|
|
55
|
+
expect(bindMock).toHaveBeenCalledWith('my-hash', NOW)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('markUsed', () => {
|
|
60
|
+
it('calls UPDATE password_reset_tokens SET used_at', async () => {
|
|
61
|
+
const { db, prepareMock } = makeMockDb()
|
|
62
|
+
await new D1PasswordResetTokenRepository(db, fixedIdGen).markUsed('prt-1', NOW)
|
|
63
|
+
expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('used_at'))
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('binds nowTimestamp and tokenId in the correct order', async () => {
|
|
67
|
+
const { db, bindMock } = makeMockDb()
|
|
68
|
+
await new D1PasswordResetTokenRepository(db, fixedIdGen).markUsed('prt-1', NOW)
|
|
69
|
+
expect(bindMock).toHaveBeenCalledWith(NOW, 'prt-1')
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('invalidatePending', () => {
|
|
74
|
+
it('calls UPDATE password_reset_tokens and includes the userId in the bound values', async () => {
|
|
75
|
+
const { db, prepareMock, bindMock } = makeMockDb()
|
|
76
|
+
await new D1PasswordResetTokenRepository(db, fixedIdGen).invalidatePending('u1', NOW)
|
|
77
|
+
expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('UPDATE password_reset_tokens'))
|
|
78
|
+
expect((bindMock.mock.calls[0] as unknown[]).includes('u1')).toBe(true)
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
})
|
|
@@ -1,56 +1,56 @@
|
|
|
1
|
-
// SPDX-License-Identifier: BUSL-1.1
|
|
2
|
-
// Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
|
|
3
|
-
// See LICENSE in the repository root for license terms.
|
|
4
|
-
|
|
5
|
-
/// <reference types="@cloudflare/workers-types" />
|
|
6
|
-
import type { IPasswordResetTokenRepository, NewPasswordResetToken, ValidatedResetToken, IIdGenerator } from '@beechcms/core'
|
|
7
|
-
|
|
8
|
-
type ValidatedResetTokenRow = {
|
|
9
|
-
id: string
|
|
10
|
-
user_id: string
|
|
11
|
-
email: string
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export class D1PasswordResetTokenRepository implements IPasswordResetTokenRepository {
|
|
15
|
-
constructor(
|
|
16
|
-
private readonly db: D1Database,
|
|
17
|
-
private readonly idGenerator: IIdGenerator,
|
|
18
|
-
) {}
|
|
19
|
-
|
|
20
|
-
async invalidatePending(userId: string, nowTimestamp: number): Promise<void> {
|
|
21
|
-
await this.db
|
|
22
|
-
.prepare('UPDATE password_reset_tokens SET used_at = ? WHERE user_id = ? AND used_at IS NULL')
|
|
23
|
-
.bind(nowTimestamp, userId)
|
|
24
|
-
.run()
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
async create(record: NewPasswordResetToken): Promise<void> {
|
|
28
|
-
const generatedId = this.idGenerator.uuid()
|
|
29
|
-
await this.db
|
|
30
|
-
.prepare('INSERT INTO password_reset_tokens (id, user_id, token_hash, expires_at) VALUES (?, ?, ?, ?)')
|
|
31
|
-
.bind(generatedId, record.userId, record.tokenHash, record.expiresAt)
|
|
32
|
-
.run()
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
async findValidByHashWithEmail(tokenHash: string, nowTimestamp: number): Promise<ValidatedResetToken | null> {
|
|
36
|
-
const row = await this.db
|
|
37
|
-
.prepare(
|
|
38
|
-
`SELECT prt.id, prt.user_id, u.email
|
|
39
|
-
FROM password_reset_tokens prt
|
|
40
|
-
JOIN users u ON u.id = prt.user_id
|
|
41
|
-
WHERE prt.token_hash = ? AND prt.expires_at > ? AND prt.used_at IS NULL`
|
|
42
|
-
)
|
|
43
|
-
.bind(tokenHash, nowTimestamp)
|
|
44
|
-
.first<ValidatedResetTokenRow>()
|
|
45
|
-
|
|
46
|
-
if (!row) return null
|
|
47
|
-
return { id: row.id, userId: row.user_id, email: row.email }
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
async markUsed(tokenId: string, nowTimestamp: number): Promise<void> {
|
|
51
|
-
await this.db
|
|
52
|
-
.prepare('UPDATE password_reset_tokens SET used_at = ? WHERE id = ?')
|
|
53
|
-
.bind(nowTimestamp, tokenId)
|
|
54
|
-
.run()
|
|
55
|
-
}
|
|
56
|
-
}
|
|
1
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
2
|
+
// Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
|
|
3
|
+
// See LICENSE in the repository root for license terms.
|
|
4
|
+
|
|
5
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
6
|
+
import type { IPasswordResetTokenRepository, NewPasswordResetToken, ValidatedResetToken, IIdGenerator } from '@beechcms/core'
|
|
7
|
+
|
|
8
|
+
type ValidatedResetTokenRow = {
|
|
9
|
+
id: string
|
|
10
|
+
user_id: string
|
|
11
|
+
email: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class D1PasswordResetTokenRepository implements IPasswordResetTokenRepository {
|
|
15
|
+
constructor(
|
|
16
|
+
private readonly db: D1Database,
|
|
17
|
+
private readonly idGenerator: IIdGenerator,
|
|
18
|
+
) {}
|
|
19
|
+
|
|
20
|
+
async invalidatePending(userId: string, nowTimestamp: number): Promise<void> {
|
|
21
|
+
await this.db
|
|
22
|
+
.prepare('UPDATE password_reset_tokens SET used_at = ? WHERE user_id = ? AND used_at IS NULL')
|
|
23
|
+
.bind(nowTimestamp, userId)
|
|
24
|
+
.run()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async create(record: NewPasswordResetToken): Promise<void> {
|
|
28
|
+
const generatedId = this.idGenerator.uuid()
|
|
29
|
+
await this.db
|
|
30
|
+
.prepare('INSERT INTO password_reset_tokens (id, user_id, token_hash, expires_at) VALUES (?, ?, ?, ?)')
|
|
31
|
+
.bind(generatedId, record.userId, record.tokenHash, record.expiresAt)
|
|
32
|
+
.run()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async findValidByHashWithEmail(tokenHash: string, nowTimestamp: number): Promise<ValidatedResetToken | null> {
|
|
36
|
+
const row = await this.db
|
|
37
|
+
.prepare(
|
|
38
|
+
`SELECT prt.id, prt.user_id, u.email
|
|
39
|
+
FROM password_reset_tokens prt
|
|
40
|
+
JOIN users u ON u.id = prt.user_id
|
|
41
|
+
WHERE prt.token_hash = ? AND prt.expires_at > ? AND prt.used_at IS NULL`
|
|
42
|
+
)
|
|
43
|
+
.bind(tokenHash, nowTimestamp)
|
|
44
|
+
.first<ValidatedResetTokenRow>()
|
|
45
|
+
|
|
46
|
+
if (!row) return null
|
|
47
|
+
return { id: row.id, userId: row.user_id, email: row.email }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async markUsed(tokenId: string, nowTimestamp: number): Promise<void> {
|
|
51
|
+
await this.db
|
|
52
|
+
.prepare('UPDATE password_reset_tokens SET used_at = ? WHERE id = ?')
|
|
53
|
+
.bind(nowTimestamp, tokenId)
|
|
54
|
+
.run()
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -1,87 +1,87 @@
|
|
|
1
|
-
// SPDX-License-Identifier: BUSL-1.1
|
|
2
|
-
// Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
|
|
3
|
-
// See LICENSE in the repository root for license terms.
|
|
4
|
-
|
|
5
|
-
import { describe, it, expect, vi } from 'vitest'
|
|
6
|
-
import type { Seed } from '@beechcms/core'
|
|
7
|
-
import { D1SearchRepository } from './d1-search.repository'
|
|
8
|
-
|
|
9
|
-
function makeMockDb(allResults: unknown[] = [], firstResult: unknown = null) {
|
|
10
|
-
const allMock = vi.fn().mockResolvedValue({ results: allResults })
|
|
11
|
-
const firstMock = vi.fn().mockResolvedValue(firstResult)
|
|
12
|
-
const bindMock = vi.fn(() => ({ all: allMock, first: firstMock }))
|
|
13
|
-
const prepareMock = vi.fn(() => ({ bind: bindMock }))
|
|
14
|
-
return { db: { prepare: prepareMock } as any, prepareMock, bindMock, allMock, firstMock }
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
const seeds: Seed[] = [
|
|
18
|
-
{
|
|
19
|
-
slug: 'posts',
|
|
20
|
-
label: 'Post',
|
|
21
|
-
displayNameAlias: 'title',
|
|
22
|
-
branches: [
|
|
23
|
-
{ id: 'br_01', alias: 'title', type: 'text', label: 'Title' },
|
|
24
|
-
{ id: 'br_02', alias: 'body', type: 'richtext', label: 'Body' },
|
|
25
|
-
],
|
|
26
|
-
},
|
|
27
|
-
]
|
|
28
|
-
|
|
29
|
-
describe('D1SearchRepository', () => {
|
|
30
|
-
it('maps FtsRow to camelCase SearchResultRow', async () => {
|
|
31
|
-
const ftsRow = {
|
|
32
|
-
entry_id: 'e1',
|
|
33
|
-
schema_slug: 'posts',
|
|
34
|
-
slug: 'hello',
|
|
35
|
-
status: 'published',
|
|
36
|
-
title: 'Hello',
|
|
37
|
-
excerpt: '<mark>Hi</mark>',
|
|
38
|
-
rank: -3.2,
|
|
39
|
-
}
|
|
40
|
-
const { db } = makeMockDb([ftsRow])
|
|
41
|
-
const result = await new D1SearchRepository(db).search(
|
|
42
|
-
{ queryText: 'hello world', schemaSlug: null, statusFilter: null, limit: 10, cursor: null },
|
|
43
|
-
seeds,
|
|
44
|
-
)
|
|
45
|
-
expect(result).toEqual([
|
|
46
|
-
{
|
|
47
|
-
entryId: 'e1',
|
|
48
|
-
schemaSlug: 'posts',
|
|
49
|
-
slug: 'hello',
|
|
50
|
-
status: 'published',
|
|
51
|
-
title: 'Hello',
|
|
52
|
-
excerpt: '<mark>Hi</mark>',
|
|
53
|
-
rank: -3.2,
|
|
54
|
-
},
|
|
55
|
-
])
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
it('returns [] on EMPTY_QUERY (single-character terms)', async () => {
|
|
59
|
-
const { db, prepareMock } = makeMockDb()
|
|
60
|
-
const result = await new D1SearchRepository(db).search(
|
|
61
|
-
{ queryText: 'a', schemaSlug: null, statusFilter: null, limit: 10, cursor: null },
|
|
62
|
-
seeds,
|
|
63
|
-
)
|
|
64
|
-
expect(result).toEqual([])
|
|
65
|
-
expect(prepareMock).not.toHaveBeenCalled()
|
|
66
|
-
})
|
|
67
|
-
|
|
68
|
-
it('count returns { total: 0 } on EMPTY_QUERY', async () => {
|
|
69
|
-
const { db } = makeMockDb()
|
|
70
|
-
const result = await new D1SearchRepository(db).count(
|
|
71
|
-
{ queryText: 'a', schemaSlug: null, statusFilter: null },
|
|
72
|
-
seeds,
|
|
73
|
-
)
|
|
74
|
-
expect(result).toEqual({ total: 0 })
|
|
75
|
-
})
|
|
76
|
-
|
|
77
|
-
it('count returns total from countSql', async () => {
|
|
78
|
-
const { db, prepareMock } = makeMockDb([], { total: 17 })
|
|
79
|
-
const result = await new D1SearchRepository(db).count(
|
|
80
|
-
{ queryText: 'hello', schemaSlug: null, statusFilter: null },
|
|
81
|
-
seeds,
|
|
82
|
-
)
|
|
83
|
-
expect(result).toEqual({ total: 17 })
|
|
84
|
-
const sql = prepareMock.mock.calls[0]![0] as string
|
|
85
|
-
expect(sql).toMatch(/SUM\(c\)/)
|
|
86
|
-
})
|
|
87
|
-
})
|
|
1
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
2
|
+
// Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
|
|
3
|
+
// See LICENSE in the repository root for license terms.
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
6
|
+
import type { Seed } from '@beechcms/core'
|
|
7
|
+
import { D1SearchRepository } from './d1-search.repository'
|
|
8
|
+
|
|
9
|
+
function makeMockDb(allResults: unknown[] = [], firstResult: unknown = null) {
|
|
10
|
+
const allMock = vi.fn().mockResolvedValue({ results: allResults })
|
|
11
|
+
const firstMock = vi.fn().mockResolvedValue(firstResult)
|
|
12
|
+
const bindMock = vi.fn(() => ({ all: allMock, first: firstMock }))
|
|
13
|
+
const prepareMock = vi.fn(() => ({ bind: bindMock }))
|
|
14
|
+
return { db: { prepare: prepareMock } as any, prepareMock, bindMock, allMock, firstMock }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const seeds: Seed[] = [
|
|
18
|
+
{
|
|
19
|
+
slug: 'posts',
|
|
20
|
+
label: 'Post',
|
|
21
|
+
displayNameAlias: 'title',
|
|
22
|
+
branches: [
|
|
23
|
+
{ id: 'br_01', alias: 'title', type: 'text', label: 'Title' },
|
|
24
|
+
{ id: 'br_02', alias: 'body', type: 'richtext', label: 'Body' },
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
describe('D1SearchRepository', () => {
|
|
30
|
+
it('maps FtsRow to camelCase SearchResultRow', async () => {
|
|
31
|
+
const ftsRow = {
|
|
32
|
+
entry_id: 'e1',
|
|
33
|
+
schema_slug: 'posts',
|
|
34
|
+
slug: 'hello',
|
|
35
|
+
status: 'published',
|
|
36
|
+
title: 'Hello',
|
|
37
|
+
excerpt: '<mark>Hi</mark>',
|
|
38
|
+
rank: -3.2,
|
|
39
|
+
}
|
|
40
|
+
const { db } = makeMockDb([ftsRow])
|
|
41
|
+
const result = await new D1SearchRepository(db).search(
|
|
42
|
+
{ queryText: 'hello world', schemaSlug: null, statusFilter: null, limit: 10, cursor: null },
|
|
43
|
+
seeds,
|
|
44
|
+
)
|
|
45
|
+
expect(result).toEqual([
|
|
46
|
+
{
|
|
47
|
+
entryId: 'e1',
|
|
48
|
+
schemaSlug: 'posts',
|
|
49
|
+
slug: 'hello',
|
|
50
|
+
status: 'published',
|
|
51
|
+
title: 'Hello',
|
|
52
|
+
excerpt: '<mark>Hi</mark>',
|
|
53
|
+
rank: -3.2,
|
|
54
|
+
},
|
|
55
|
+
])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('returns [] on EMPTY_QUERY (single-character terms)', async () => {
|
|
59
|
+
const { db, prepareMock } = makeMockDb()
|
|
60
|
+
const result = await new D1SearchRepository(db).search(
|
|
61
|
+
{ queryText: 'a', schemaSlug: null, statusFilter: null, limit: 10, cursor: null },
|
|
62
|
+
seeds,
|
|
63
|
+
)
|
|
64
|
+
expect(result).toEqual([])
|
|
65
|
+
expect(prepareMock).not.toHaveBeenCalled()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('count returns { total: 0 } on EMPTY_QUERY', async () => {
|
|
69
|
+
const { db } = makeMockDb()
|
|
70
|
+
const result = await new D1SearchRepository(db).count(
|
|
71
|
+
{ queryText: 'a', schemaSlug: null, statusFilter: null },
|
|
72
|
+
seeds,
|
|
73
|
+
)
|
|
74
|
+
expect(result).toEqual({ total: 0 })
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('count returns total from countSql', async () => {
|
|
78
|
+
const { db, prepareMock } = makeMockDb([], { total: 17 })
|
|
79
|
+
const result = await new D1SearchRepository(db).count(
|
|
80
|
+
{ queryText: 'hello', schemaSlug: null, statusFilter: null },
|
|
81
|
+
seeds,
|
|
82
|
+
)
|
|
83
|
+
expect(result).toEqual({ total: 17 })
|
|
84
|
+
const sql = prepareMock.mock.calls[0]![0] as string
|
|
85
|
+
expect(sql).toMatch(/SUM\(c\)/)
|
|
86
|
+
})
|
|
87
|
+
})
|
|
@@ -1,88 +1,88 @@
|
|
|
1
|
-
// SPDX-License-Identifier: BUSL-1.1
|
|
2
|
-
// Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
|
|
3
|
-
// See LICENSE in the repository root for license terms.
|
|
4
|
-
|
|
5
|
-
/// <reference types="@cloudflare/workers-types" />
|
|
6
|
-
import type {
|
|
7
|
-
Seed,
|
|
8
|
-
ISearchRepository,
|
|
9
|
-
SearchQueryOptions,
|
|
10
|
-
SearchResultRow,
|
|
11
|
-
SearchCountResult,
|
|
12
|
-
} from '@beechcms/core'
|
|
13
|
-
import { buildFtsQuery, type FtsRow } from '../search-utils'
|
|
14
|
-
|
|
15
|
-
const EMPTY_QUERY_ERROR = 'EMPTY_QUERY'
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* D1-backed implementation of {@link ISearchRepository}.
|
|
19
|
-
*
|
|
20
|
-
* Delegates SQL composition to the pure `buildFtsQuery` helper to keep this
|
|
21
|
-
* class focused on D1 wiring. Returns raw rows; the route handler is
|
|
22
|
-
* responsible for mapping them via `mapFtsRow`.
|
|
23
|
-
*/
|
|
24
|
-
export class D1SearchRepository implements ISearchRepository {
|
|
25
|
-
constructor(private readonly database: D1Database) {}
|
|
26
|
-
|
|
27
|
-
async search(options: SearchQueryOptions, seeds: Seed[]): Promise<SearchResultRow[]> {
|
|
28
|
-
let queryParts
|
|
29
|
-
try {
|
|
30
|
-
queryParts = buildFtsQuery(
|
|
31
|
-
{
|
|
32
|
-
q: options.queryText,
|
|
33
|
-
schemaSlug: options.schemaSlug,
|
|
34
|
-
status: options.statusFilter,
|
|
35
|
-
limit: options.limit,
|
|
36
|
-
cursor: options.cursor,
|
|
37
|
-
},
|
|
38
|
-
seeds,
|
|
39
|
-
)
|
|
40
|
-
} catch (error) {
|
|
41
|
-
if (error instanceof Error && error.message === EMPTY_QUERY_ERROR) return []
|
|
42
|
-
throw error
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const result = await this.database.prepare(queryParts.sql).bind(...queryParts.binds).all<FtsRow>()
|
|
46
|
-
return (result.results ?? []).map(mapFtsRowToResultRow)
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
async count(
|
|
50
|
-
options: Omit<SearchQueryOptions, 'limit' | 'cursor'>,
|
|
51
|
-
seeds: Seed[],
|
|
52
|
-
): Promise<SearchCountResult> {
|
|
53
|
-
let queryParts
|
|
54
|
-
try {
|
|
55
|
-
queryParts = buildFtsQuery(
|
|
56
|
-
{
|
|
57
|
-
q: options.queryText,
|
|
58
|
-
schemaSlug: options.schemaSlug,
|
|
59
|
-
status: options.statusFilter,
|
|
60
|
-
limit: 0,
|
|
61
|
-
cursor: null,
|
|
62
|
-
},
|
|
63
|
-
seeds,
|
|
64
|
-
)
|
|
65
|
-
} catch (error) {
|
|
66
|
-
if (error instanceof Error && error.message === EMPTY_QUERY_ERROR) return { total: 0 }
|
|
67
|
-
throw error
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const row = await this.database
|
|
71
|
-
.prepare(queryParts.countSql)
|
|
72
|
-
.bind(...queryParts.countBinds)
|
|
73
|
-
.first<{ total: number }>()
|
|
74
|
-
return { total: row?.total ?? 0 }
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function mapFtsRowToResultRow(row: FtsRow): SearchResultRow {
|
|
79
|
-
return {
|
|
80
|
-
entryId: row.entry_id,
|
|
81
|
-
schemaSlug: row.schema_slug,
|
|
82
|
-
slug: row.slug,
|
|
83
|
-
status: row.status,
|
|
84
|
-
title: row.title,
|
|
85
|
-
excerpt: row.excerpt,
|
|
86
|
-
rank: row.rank,
|
|
87
|
-
}
|
|
88
|
-
}
|
|
1
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
2
|
+
// Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
|
|
3
|
+
// See LICENSE in the repository root for license terms.
|
|
4
|
+
|
|
5
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
6
|
+
import type {
|
|
7
|
+
Seed,
|
|
8
|
+
ISearchRepository,
|
|
9
|
+
SearchQueryOptions,
|
|
10
|
+
SearchResultRow,
|
|
11
|
+
SearchCountResult,
|
|
12
|
+
} from '@beechcms/core'
|
|
13
|
+
import { buildFtsQuery, type FtsRow } from '../search-utils'
|
|
14
|
+
|
|
15
|
+
const EMPTY_QUERY_ERROR = 'EMPTY_QUERY'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* D1-backed implementation of {@link ISearchRepository}.
|
|
19
|
+
*
|
|
20
|
+
* Delegates SQL composition to the pure `buildFtsQuery` helper to keep this
|
|
21
|
+
* class focused on D1 wiring. Returns raw rows; the route handler is
|
|
22
|
+
* responsible for mapping them via `mapFtsRow`.
|
|
23
|
+
*/
|
|
24
|
+
export class D1SearchRepository implements ISearchRepository {
|
|
25
|
+
constructor(private readonly database: D1Database) {}
|
|
26
|
+
|
|
27
|
+
async search(options: SearchQueryOptions, seeds: Seed[]): Promise<SearchResultRow[]> {
|
|
28
|
+
let queryParts
|
|
29
|
+
try {
|
|
30
|
+
queryParts = buildFtsQuery(
|
|
31
|
+
{
|
|
32
|
+
q: options.queryText,
|
|
33
|
+
schemaSlug: options.schemaSlug,
|
|
34
|
+
status: options.statusFilter,
|
|
35
|
+
limit: options.limit,
|
|
36
|
+
cursor: options.cursor,
|
|
37
|
+
},
|
|
38
|
+
seeds,
|
|
39
|
+
)
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (error instanceof Error && error.message === EMPTY_QUERY_ERROR) return []
|
|
42
|
+
throw error
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const result = await this.database.prepare(queryParts.sql).bind(...queryParts.binds).all<FtsRow>()
|
|
46
|
+
return (result.results ?? []).map(mapFtsRowToResultRow)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async count(
|
|
50
|
+
options: Omit<SearchQueryOptions, 'limit' | 'cursor'>,
|
|
51
|
+
seeds: Seed[],
|
|
52
|
+
): Promise<SearchCountResult> {
|
|
53
|
+
let queryParts
|
|
54
|
+
try {
|
|
55
|
+
queryParts = buildFtsQuery(
|
|
56
|
+
{
|
|
57
|
+
q: options.queryText,
|
|
58
|
+
schemaSlug: options.schemaSlug,
|
|
59
|
+
status: options.statusFilter,
|
|
60
|
+
limit: 0,
|
|
61
|
+
cursor: null,
|
|
62
|
+
},
|
|
63
|
+
seeds,
|
|
64
|
+
)
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (error instanceof Error && error.message === EMPTY_QUERY_ERROR) return { total: 0 }
|
|
67
|
+
throw error
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const row = await this.database
|
|
71
|
+
.prepare(queryParts.countSql)
|
|
72
|
+
.bind(...queryParts.countBinds)
|
|
73
|
+
.first<{ total: number }>()
|
|
74
|
+
return { total: row?.total ?? 0 }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function mapFtsRowToResultRow(row: FtsRow): SearchResultRow {
|
|
79
|
+
return {
|
|
80
|
+
entryId: row.entry_id,
|
|
81
|
+
schemaSlug: row.schema_slug,
|
|
82
|
+
slug: row.slug,
|
|
83
|
+
status: row.status,
|
|
84
|
+
title: row.title,
|
|
85
|
+
excerpt: row.excerpt,
|
|
86
|
+
rank: row.rank,
|
|
87
|
+
}
|
|
88
|
+
}
|