@spfn/core 0.3.0-beta.5 → 0.3.0-beta.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +132 -4
- package/dist/db/index.d.ts +173 -27
- package/dist/db/index.js +192 -57
- package/dist/db/index.js.map +1 -1
- package/dist/nextjs/index.d.ts +18 -1
- package/dist/nextjs/index.js +40 -1
- package/dist/nextjs/index.js.map +1 -1
- package/dist/nextjs/server.d.ts +34 -1
- package/dist/nextjs/server.js +14 -0
- package/dist/nextjs/server.js.map +1 -1
- package/docs/file-upload.md +195 -333
- package/package.json +6 -5
- package/src/cache/README.md +330 -0
- package/src/codegen/README.md +516 -0
- package/src/config/README.md +326 -0
- package/src/contract/README.md +326 -0
- package/src/db/README.md +589 -0
- package/src/db/manager/README.md +500 -0
- package/src/db/schema/README.md +344 -0
- package/src/db/transaction/README.md +822 -0
- package/src/env/README.md +651 -0
- package/src/errors/README.md +429 -0
- package/src/event/README.md +736 -0
- package/src/job/README.md +514 -0
- package/src/logger/README.md +321 -0
- package/src/middleware/README.md +634 -0
- package/src/nextjs/README.md +608 -0
- package/src/route/README.md +738 -0
- package/src/security/README.md +100 -0
- package/src/server/README.md +704 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
# @spfn/core/db/schema — Drizzle entity column helpers & schema namespacing
|
|
2
|
+
|
|
3
|
+
Reusable column-definition helpers and package-scoped PostgreSQL schema utilities for
|
|
4
|
+
defining Drizzle ORM entities (`pgTable`) with consistent, type-safe boilerplate.
|
|
5
|
+
|
|
6
|
+
## Import paths
|
|
7
|
+
|
|
8
|
+
There is **no** `@spfn/core/db/schema` subpath export. Everything in this module is
|
|
9
|
+
re-exported from `@spfn/core/db`. Import from there.
|
|
10
|
+
|
|
11
|
+
```typescript
|
|
12
|
+
import {
|
|
13
|
+
id, uuid, timestamps,
|
|
14
|
+
foreignKey, optionalForeignKey,
|
|
15
|
+
auditFields, publishingFields, softDelete,
|
|
16
|
+
verificationTimestamp, utcTimestamp, enumText, typedJsonb,
|
|
17
|
+
createSchema, packageNameToSchema, getSchemaInfo,
|
|
18
|
+
} from '@spfn/core/db';
|
|
19
|
+
|
|
20
|
+
// Column primitives (text, integer, index, etc.) come from drizzle directly:
|
|
21
|
+
import { pgTable, text } from 'drizzle-orm/pg-core';
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
> There is **no** root `.` export for `@spfn/core`. `import { id } from '@spfn/core'`
|
|
25
|
+
> does **not** resolve — always use `@spfn/core/db`. (Some JSDoc examples in the source
|
|
26
|
+
> still show `from '@spfn/core'`; that is stale.)
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Public API (complete)
|
|
31
|
+
|
|
32
|
+
Column helpers (`entity-helper.ts`):
|
|
33
|
+
|
|
34
|
+
- `id()` — bigserial primary key
|
|
35
|
+
- `uuid()` — uuid primary key (`gen_random_uuid()`)
|
|
36
|
+
- `timestamps()` — `createdAt` + `updatedAt` (timestamptz, not null, both written by the database clock)
|
|
37
|
+
- `foreignKey(name, reference, options?)` — required FK (bigint, cascade by default)
|
|
38
|
+
- `optionalForeignKey(name, reference, options?)` — nullable FK (set null by default)
|
|
39
|
+
- `auditFields()` — `createdBy` + `updatedBy` (text, nullable)
|
|
40
|
+
- `publishingFields()` — `publishedAt` (timestamptz, nullable) + `publishedBy` (text)
|
|
41
|
+
- `softDelete()` — `deletedAt` (timestamptz, nullable) + `deletedBy` (text)
|
|
42
|
+
- `verificationTimestamp(fieldName)` — single nullable timestamptz, `{fieldName}At`
|
|
43
|
+
- `utcTimestamp(fieldName, mode?)` — single timestamptz column (chainable)
|
|
44
|
+
- `enumText(fieldName, values)` — text column with enum constraint (chainable)
|
|
45
|
+
- `typedJsonb<T>(fieldName)` — jsonb column typed as `T` (chainable)
|
|
46
|
+
|
|
47
|
+
Schema namespacing (`schema-helper.ts`):
|
|
48
|
+
|
|
49
|
+
- `createSchema(packageName)` → `PgSchema` (drizzle `pgSchema`) for `schema.table(...)`
|
|
50
|
+
- `packageNameToSchema(packageName)` → `string` schema name
|
|
51
|
+
- `getSchemaInfo(packageName)` → `{ schemaName, isScoped, scope }`
|
|
52
|
+
|
|
53
|
+
> The following **do not exist** in the current code — do not use them:
|
|
54
|
+
> `autoUpdateTimestamp()`, `statusEnum()`, the `timestamps({ autoUpdate })` option, and
|
|
55
|
+
> the `__autoUpdate` marker. Earlier docs referenced these; they were removed. For
|
|
56
|
+
> auto-updating `updatedAt`, set it manually (`.set({ updatedAt: new Date() })`) — the
|
|
57
|
+
> helpers do not auto-update it.
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## Quick Start
|
|
62
|
+
|
|
63
|
+
```typescript
|
|
64
|
+
// src/server/entities/users.ts
|
|
65
|
+
import { pgTable, text, boolean } from 'drizzle-orm/pg-core';
|
|
66
|
+
import { id, timestamps, enumText } from '@spfn/core/db';
|
|
67
|
+
|
|
68
|
+
export const USER_ROLES = ['admin', 'user', 'guest'] as const;
|
|
69
|
+
|
|
70
|
+
export const users = pgTable('users', {
|
|
71
|
+
id: id(),
|
|
72
|
+
email: text('email').notNull().unique(),
|
|
73
|
+
name: text('name').notNull(),
|
|
74
|
+
role: enumText('role', USER_ROLES).notNull().default('user'),
|
|
75
|
+
isActive: boolean('is_active').notNull().default(true),
|
|
76
|
+
...timestamps(),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Type inference straight from the table
|
|
80
|
+
export type User = typeof users.$inferSelect;
|
|
81
|
+
export type NewUser = typeof users.$inferInsert;
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Spread helpers that return multiple columns (`timestamps()`, `auditFields()`,
|
|
85
|
+
`publishingFields()`, `softDelete()`, `verificationTimestamp()`); call directly the ones
|
|
86
|
+
that return a single column (`id()`, `uuid()`, `foreignKey()`, `enumText()`,
|
|
87
|
+
`utcTimestamp()`, `typedJsonb()`).
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Column helpers
|
|
92
|
+
|
|
93
|
+
### Primary keys — `id()`, `uuid()`
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
id: id(), // bigserial('id', { mode: 'number' }).primaryKey() → bigserial PK
|
|
97
|
+
id: uuid(), // uuid('id').defaultRandom().primaryKey() → uuid PK
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`id()` is the default. Use `uuid()` for distributed/public-facing IDs. Both produce a
|
|
101
|
+
column named `id`.
|
|
102
|
+
|
|
103
|
+
### `timestamps()`
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
...timestamps(),
|
|
107
|
+
// createdAt: timestamptz, defaultNow(), notNull()
|
|
108
|
+
// updatedAt: timestamptz, defaultNow(), notNull(), $onUpdate(() => sql`now()`)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
DB column names are `created_at` / `updated_at`. Both come from the database clock —
|
|
112
|
+
`now()` on insert through the column default, and `now()` again on update, which Drizzle
|
|
113
|
+
adds to the SET clause for you. **Do not pass `updatedAt` yourself:**
|
|
114
|
+
|
|
115
|
+
```typescript
|
|
116
|
+
await db.update(users)
|
|
117
|
+
.set({ name: 'new' }) // updated_at = now() is added automatically
|
|
118
|
+
.where(eq(users.id, userId));
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
An explicit value wins over the automatic one, so stamping `new Date()` reads a second
|
|
122
|
+
clock. In production the application host and the database host are different machines:
|
|
123
|
+
a row could then record an update earlier than its own creation, and ordering rows by
|
|
124
|
+
`updated_at` would order by whichever host's clock ran ahead.
|
|
125
|
+
|
|
126
|
+
`now()` is the transaction's start time. It is not a commit-order sequence — a sync
|
|
127
|
+
cursor or anything else that needs true ordering needs a sequence column, not a
|
|
128
|
+
timestamp.
|
|
129
|
+
|
|
130
|
+
### `foreignKey(name, reference, options?)` / `optionalForeignKey(...)`
|
|
131
|
+
|
|
132
|
+
```typescript
|
|
133
|
+
authorId: foreignKey('author', () => users.id), // author_id BIGINT NOT NULL, ON DELETE CASCADE
|
|
134
|
+
categoryId: optionalForeignKey('category', () => cats.id), // category_id BIGINT (nullable), ON DELETE SET NULL
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
- Column name is `{name}_id`. Both produce a **bigint** column (`mode: 'number'`) — pair
|
|
138
|
+
with `id()` (bigserial), not `uuid()`.
|
|
139
|
+
- `options.onDelete`: `'cascade' | 'set null' | 'restrict' | 'no action'`. Defaults:
|
|
140
|
+
`foreignKey` → `'cascade'`, `optionalForeignKey` → `'set null'`.
|
|
141
|
+
- There is **no** `onUpdate` option on these helpers. For `onUpdate` (or FK to a uuid PK),
|
|
142
|
+
use drizzle's table-level `foreignKey({ columns, foreignColumns, onUpdate })` instead.
|
|
143
|
+
|
|
144
|
+
### `auditFields()`, `publishingFields()`, `softDelete()`
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
...auditFields(), // created_by TEXT, updated_by TEXT (nullable)
|
|
148
|
+
...publishingFields(), // published_at TIMESTAMPTZ (nullable), published_by TEXT
|
|
149
|
+
...softDelete(), // deleted_at TIMESTAMPTZ (nullable), deleted_by TEXT
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Soft-delete query pattern — filter manually, there is no automatic scoping:
|
|
153
|
+
|
|
154
|
+
```typescript
|
|
155
|
+
await db.select().from(posts).where(isNull(posts.deletedAt));
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### `verificationTimestamp(fieldName)`
|
|
159
|
+
|
|
160
|
+
Single nullable timestamptz. The JS property is `{fieldName}At`, the DB column is
|
|
161
|
+
`snake_case(fieldName) + '_at'`.
|
|
162
|
+
|
|
163
|
+
```typescript
|
|
164
|
+
...verificationTimestamp('emailVerified'), // prop emailVerifiedAt → col email_verified_at
|
|
165
|
+
...verificationTimestamp('phoneVerified'), // prop phoneVerifiedAt → col phone_verified_at
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### `utcTimestamp(fieldName, mode?)`
|
|
169
|
+
|
|
170
|
+
Single timestamptz column. `fieldName` is the **DB column name** (snake_case, unlike
|
|
171
|
+
`verificationTimestamp`). `mode` is `'date'` (default, `Date`) or `'string'` (ISO string).
|
|
172
|
+
Chainable.
|
|
173
|
+
|
|
174
|
+
```typescript
|
|
175
|
+
scheduledAt: utcTimestamp('scheduled_at').notNull(),
|
|
176
|
+
lastLoginAt: utcTimestamp('last_login_at').defaultNow().notNull(),
|
|
177
|
+
processedAt: utcTimestamp('processed_at', 'string'), // ISO string
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### `enumText(fieldName, values)`
|
|
181
|
+
|
|
182
|
+
Text column constrained to a const tuple; the value type is inferred. Chainable.
|
|
183
|
+
|
|
184
|
+
```typescript
|
|
185
|
+
export const USER_STATUSES = ['active', 'inactive', 'suspended'] as const;
|
|
186
|
+
export type UserStatus = typeof USER_STATUSES[number];
|
|
187
|
+
|
|
188
|
+
status: enumText('status', USER_STATUSES).notNull().default('active'),
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Backing type is plain `text` (no PG enum type), so adding values needs **no migration**.
|
|
192
|
+
|
|
193
|
+
### `typedJsonb<T>(fieldName)`
|
|
194
|
+
|
|
195
|
+
jsonb column typed as `T` — avoids `unknown` / `as any` on reads. Chainable.
|
|
196
|
+
|
|
197
|
+
```typescript
|
|
198
|
+
type Metadata = { theme: 'light' | 'dark'; settings: Record<string, unknown> };
|
|
199
|
+
|
|
200
|
+
metadata: typedJsonb<Metadata>('metadata').notNull(),
|
|
201
|
+
tags: typedJsonb<string[]>('tags'),
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## Schema namespacing
|
|
207
|
+
|
|
208
|
+
Isolate a package's tables under a dedicated PostgreSQL schema so multiple SPFN packages
|
|
209
|
+
share one database without table-name collisions.
|
|
210
|
+
|
|
211
|
+
```typescript
|
|
212
|
+
import { createSchema, id, timestamps } from '@spfn/core/db';
|
|
213
|
+
import { text } from 'drizzle-orm/pg-core';
|
|
214
|
+
|
|
215
|
+
const schema = createSchema('@spfn/cms'); // PG schema: spfn_cms
|
|
216
|
+
|
|
217
|
+
export const labels = schema.table('labels', {
|
|
218
|
+
id: id(),
|
|
219
|
+
name: text('name').notNull(),
|
|
220
|
+
...timestamps(),
|
|
221
|
+
});
|
|
222
|
+
// → table spfn_cms.labels
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Naming rules (`packageNameToSchema`): strip `@`, replace `/` and `-` with `_`.
|
|
226
|
+
|
|
227
|
+
```typescript
|
|
228
|
+
packageNameToSchema('@spfn/cms'); // 'spfn_cms'
|
|
229
|
+
packageNameToSchema('@company/auth'); // 'company_auth'
|
|
230
|
+
packageNameToSchema('spfn-storage'); // 'spfn_storage'
|
|
231
|
+
|
|
232
|
+
getSchemaInfo('@spfn/cms');
|
|
233
|
+
// { schemaName: 'spfn_cms', isScoped: true, scope: 'spfn' }
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Use `schema.table(...)` instead of `pgTable(...)` for every table in that package. The
|
|
237
|
+
column helpers above work identically inside `schema.table`.
|
|
238
|
+
|
|
239
|
+
---
|
|
240
|
+
|
|
241
|
+
## Pitfalls & anti-patterns
|
|
242
|
+
|
|
243
|
+
- **`@spfn/core/db/schema` is not an export path; `@spfn/core` (bare) has no root export
|
|
244
|
+
either.** Import everything from `@spfn/core/db`.
|
|
245
|
+
- **`id` is bigint, not uuid.** `id()` and `foreignKey()`/`optionalForeignKey()` are all
|
|
246
|
+
bigint-based. Don't point a `foreignKey()` (bigint) at a `uuid()` PK — types won't match.
|
|
247
|
+
For a uuid FK, use drizzle's table-level `foreignKey({ columns, foreignColumns })`.
|
|
248
|
+
- **`timestamps()` takes no arguments and does not auto-update `updatedAt`.** Calls like
|
|
249
|
+
`timestamps({ autoUpdate: true })` are from an old API and will fail/no-op. Set
|
|
250
|
+
`updatedAt: new Date()` yourself on updates.
|
|
251
|
+
- **`autoUpdateTimestamp()`, `statusEnum()`, and the `__autoUpdate` marker do not exist.**
|
|
252
|
+
Replace `statusEnum([...])` with `enumText('status', [...] as const).notNull().default(...)`.
|
|
253
|
+
- **`verificationTimestamp(name)` takes a camelCase logical name; `utcTimestamp(col)` takes
|
|
254
|
+
a snake_case DB column name.** They differ — `verificationTimestamp('emailVerified')`
|
|
255
|
+
yields column `email_verified_at`, but `utcTimestamp('emailVerified')` would create a
|
|
256
|
+
column literally named `emailVerified`.
|
|
257
|
+
- **`enumText` is plain text + CHECK, not a PG enum type.** Adding values requires no
|
|
258
|
+
migration; renaming/removing the column still does.
|
|
259
|
+
- **Exported tables must be reachable by drizzle-kit to land in migrations.** Re-export
|
|
260
|
+
every entity (e.g. `src/server/entities/index.ts → export * from './users'`) and point
|
|
261
|
+
drizzle config at it; an unexported table generates no migration.
|
|
262
|
+
- **When using `createSchema`, all tables in that package must use `schema.table(...)`.**
|
|
263
|
+
Mixing `pgTable(...)` puts that table in the default `public` schema.
|
|
264
|
+
|
|
265
|
+
---
|
|
266
|
+
|
|
267
|
+
## Complete example
|
|
268
|
+
|
|
269
|
+
```typescript
|
|
270
|
+
// src/server/entities/posts.ts
|
|
271
|
+
import { pgTable, text, index } from 'drizzle-orm/pg-core';
|
|
272
|
+
import { sql } from 'drizzle-orm';
|
|
273
|
+
import {
|
|
274
|
+
id, foreignKey, optionalForeignKey,
|
|
275
|
+
enumText, typedJsonb,
|
|
276
|
+
timestamps, auditFields, publishingFields, softDelete,
|
|
277
|
+
} from '@spfn/core/db';
|
|
278
|
+
import { users } from './users';
|
|
279
|
+
|
|
280
|
+
export const POST_STATUSES = ['draft', 'published', 'archived'] as const;
|
|
281
|
+
export type PostStatus = typeof POST_STATUSES[number];
|
|
282
|
+
|
|
283
|
+
type PostMeta = { seoTitle?: string; tags: string[] };
|
|
284
|
+
|
|
285
|
+
export const posts = pgTable('posts', {
|
|
286
|
+
id: id(),
|
|
287
|
+
title: text('title').notNull(),
|
|
288
|
+
slug: text('slug').notNull().unique(),
|
|
289
|
+
|
|
290
|
+
authorId: foreignKey('author', () => users.id), // NOT NULL, cascade
|
|
291
|
+
reviewerId: optionalForeignKey('reviewer', () => users.id), // nullable, set null
|
|
292
|
+
|
|
293
|
+
status: enumText('status', POST_STATUSES).notNull().default('draft'),
|
|
294
|
+
meta: typedJsonb<PostMeta>('meta'),
|
|
295
|
+
|
|
296
|
+
...timestamps(),
|
|
297
|
+
...auditFields(),
|
|
298
|
+
...publishingFields(),
|
|
299
|
+
...softDelete(),
|
|
300
|
+
}, (table) => [
|
|
301
|
+
index('posts_status_idx').on(table.status),
|
|
302
|
+
index('posts_active_idx').on(table.slug).where(sql`${table.deletedAt} is null`),
|
|
303
|
+
]);
|
|
304
|
+
|
|
305
|
+
export type Post = typeof posts.$inferSelect;
|
|
306
|
+
export type NewPost = typeof posts.$inferInsert;
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
```typescript
|
|
310
|
+
// src/server/entities/index.ts — must re-export so drizzle-kit sees every table
|
|
311
|
+
export * from './users';
|
|
312
|
+
export * from './posts';
|
|
313
|
+
export * from './relations';
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
Indexes, composite primary keys, unique/check constraints, and `relations()` are standard
|
|
317
|
+
drizzle features (table callback / `drizzle-orm`) — this module does not wrap them; use
|
|
318
|
+
drizzle directly.
|
|
319
|
+
|
|
320
|
+
---
|
|
321
|
+
|
|
322
|
+
## Types reference
|
|
323
|
+
|
|
324
|
+
```typescript
|
|
325
|
+
// foreignKey / optionalForeignKey options
|
|
326
|
+
type FkOptions = { onDelete?: 'cascade' | 'set null' | 'restrict' | 'no action' };
|
|
327
|
+
|
|
328
|
+
// utcTimestamp mode
|
|
329
|
+
type UtcMode = 'date' | 'string'; // default 'date'
|
|
330
|
+
|
|
331
|
+
// enumText values
|
|
332
|
+
type EnumValues = readonly [string, ...string[]];
|
|
333
|
+
|
|
334
|
+
// getSchemaInfo return
|
|
335
|
+
type SchemaInfo = { schemaName: string; isScoped: boolean; scope: string | null };
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
Column return types are drizzle column builders (`PgColumn`-based); chain
|
|
339
|
+
`.notNull()`, `.default(...)`, `.unique()`, `.references(...)` as usual.
|
|
340
|
+
|
|
341
|
+
## Related
|
|
342
|
+
|
|
343
|
+
- [@spfn/core/db](../README.md) — DB manager, transactions, repository, postgres errors
|
|
344
|
+
- [Drizzle ORM](https://orm.drizzle.team/) — `pgTable`, `pgSchema`, indexes, relations
|