@spfn/core 0.3.0-beta.33 → 0.3.0-beta.34
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 +1 -1
- package/dist/db/index.d.ts +254 -84
- package/dist/db/index.js +400 -25
- package/dist/db/index.js.map +1 -1
- package/package.json +1 -1
- package/src/db/README.md +109 -6
- package/src/db/schema/README.md +19 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spfn/core",
|
|
3
|
-
"version": "0.3.0-beta.
|
|
3
|
+
"version": "0.3.0-beta.34",
|
|
4
4
|
"description": "Full-stack TypeScript backend for Next.js: file-based typed routes, Drizzle entities and repositories, PostgreSQL transactions and a generated end-to-end client, in one fixed vertical slice per feature",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
package/src/db/README.md
CHANGED
|
@@ -18,14 +18,14 @@ import {
|
|
|
18
18
|
findOne, findMany, create, createMany, upsert,
|
|
19
19
|
updateOne, updateMany, deleteOne, deleteMany, count,
|
|
20
20
|
// Repository
|
|
21
|
-
BaseRepository, RepositoryError,
|
|
21
|
+
BaseRepository, TenantRepository, RepositoryError,
|
|
22
22
|
// Connection (sub-module: manager/)
|
|
23
23
|
initDatabase, getDatabase, closeDatabase, getDatabaseInfo,
|
|
24
24
|
// Transactions (sub-module: transaction/)
|
|
25
25
|
Transactional, runWithTransaction, getTransaction,
|
|
26
26
|
onBeforeCommit, onAfterCommit, onAfterRollback,
|
|
27
27
|
// Schema helpers (sub-module: schema/)
|
|
28
|
-
id, uuid, timestamps, foreignKey, enumText, typedJsonb, createSchema,
|
|
28
|
+
id, uuid, timestamps, foreignKey, tenantKey, enumText, typedJsonb, createSchema,
|
|
29
29
|
// Error mapping
|
|
30
30
|
fromPostgresError,
|
|
31
31
|
} from '@spfn/core/db';
|
|
@@ -44,8 +44,9 @@ Everything re-exported from `@spfn/core/db` (`src/db/index.ts`):
|
|
|
44
44
|
`findOne`, `findMany`, `create`, `createMany`, `upsert`, `updateOne`, `updateMany`,
|
|
45
45
|
`deleteOne`, `deleteMany`, `count`
|
|
46
46
|
|
|
47
|
-
**Repository** (`repository.ts`):
|
|
48
|
-
`BaseRepository` (abstract class), `
|
|
47
|
+
**Repository** (`repository.ts`, `tenant/`):
|
|
48
|
+
`BaseRepository` (abstract class), `TenantRepository` (abstract class, tables shared by many
|
|
49
|
+
tenants), `RepositoryError` (class), `RepositoryDatabase` (type), `CountOptions` (type)
|
|
49
50
|
|
|
50
51
|
**PostgreSQL error mapping** (`postgres-errors.ts`):
|
|
51
52
|
`fromPostgresError`
|
|
@@ -69,7 +70,7 @@ Types: `TransactionContext`, `TransactionDB`, `TransactionalOptions`,
|
|
|
69
70
|
|
|
70
71
|
**Schema helpers** (sub-module — see [schema/README](./schema/README.md), re-exported via
|
|
71
72
|
`export * from './schema'`):
|
|
72
|
-
`id`, `uuid`, `timestamps`, `foreignKey`, `optionalForeignKey`, `auditFields`,
|
|
73
|
+
`id`, `uuid`, `timestamps`, `foreignKey`, `optionalForeignKey`, `tenantKey`, `auditFields`,
|
|
73
74
|
`publishingFields`, `softDelete`, `verificationTimestamp`, `utcTimestamp`, `enumText`,
|
|
74
75
|
`typedJsonb`, `createSchema`, `packageNameToSchema`, `getSchemaInfo`
|
|
75
76
|
|
|
@@ -250,6 +251,7 @@ inside repository methods.
|
|
|
250
251
|
|--------|---------|
|
|
251
252
|
| `_findOne(table, where)` | `T \| null` |
|
|
252
253
|
| `_findMany(table, options?)` | `T[]` |
|
|
254
|
+
| `_findManyKeyset(table, { cursorColumn, limit, after?, order?, where? })` | `T[]` |
|
|
253
255
|
| `_create(table, data)` | `T` |
|
|
254
256
|
| `_createMany(table, data[])` | `T[]` |
|
|
255
257
|
| `_upsert(table, data, { target, set? })` | `T` |
|
|
@@ -257,7 +259,12 @@ inside repository methods.
|
|
|
257
259
|
| `_updateMany(table, where, data)` | `T[]` |
|
|
258
260
|
| `_deleteOne(table, where)` | `T \| null` |
|
|
259
261
|
| `_deleteMany(table, where)` | `T[]` |
|
|
260
|
-
| `_count(table, where?)` | `number` |
|
|
262
|
+
| `_count(table, where?, { cap? })` | `number` — with `cap`, at most `cap + 1` |
|
|
263
|
+
|
|
264
|
+
`_count` with `cap` stops counting past `cap` rows: it reads at most `cap + 1` rows through a
|
|
265
|
+
limited subquery, and a result of `cap + 1` means "more than `cap`". A list label such as
|
|
266
|
+
"1,000+" needs nothing more, and the count no longer scans every matching row on each
|
|
267
|
+
request.
|
|
261
268
|
|
|
262
269
|
These are `protected` — calling `userRepo._findOne(...)` from outside is a TypeScript
|
|
263
270
|
error. Expose a domain method (`findById`) instead.
|
|
@@ -324,6 +331,102 @@ export class UserRepository extends BaseRepository<AppRelations, AppDatabase>
|
|
|
324
331
|
|
|
325
332
|
---
|
|
326
333
|
|
|
334
|
+
## TenantRepository (`tenant/`) — tables shared by many tenants
|
|
335
|
+
|
|
336
|
+
For an app that keeps many tenants (workspaces, organizations, customers) in the same
|
|
337
|
+
tables. Mark a table's tenant column with `tenantKey()`; extend `TenantRepository` instead of
|
|
338
|
+
`BaseRepository`. Every method takes the tenant id first and writes the tenant condition
|
|
339
|
+
into the query itself, so a query this class builds cannot reach another tenant's rows.
|
|
340
|
+
Packages that never call `tenantKey()` are unaffected.
|
|
341
|
+
|
|
342
|
+
```typescript
|
|
343
|
+
import { createSchema, foreignKey, id, tenantKey, timestamps, TenantRepository } from '@spfn/core/db';
|
|
344
|
+
import { index, text } from 'drizzle-orm/pg-core';
|
|
345
|
+
import { eq } from 'drizzle-orm';
|
|
346
|
+
|
|
347
|
+
const schema = createSchema('@acme/notes');
|
|
348
|
+
|
|
349
|
+
export const authors = schema.table('authors', {
|
|
350
|
+
id: id(),
|
|
351
|
+
tenantId: tenantKey(), // tenant_id bigint not null
|
|
352
|
+
name: text('name').notNull(),
|
|
353
|
+
}, (t) => [index('authors_tenant_id_idx').on(t.tenantId, t.id)]);
|
|
354
|
+
|
|
355
|
+
export const notes = schema.table('notes', {
|
|
356
|
+
id: id(),
|
|
357
|
+
tenantId: tenantKey(),
|
|
358
|
+
authorId: foreignKey('author', () => authors.id),
|
|
359
|
+
title: text('title').notNull(),
|
|
360
|
+
...timestamps(),
|
|
361
|
+
}, (t) => [index('notes_tenant_id_idx').on(t.tenantId, t.id)]);
|
|
362
|
+
|
|
363
|
+
export class NoteRepository extends TenantRepository
|
|
364
|
+
{
|
|
365
|
+
async list(tenantId: number, after?: number)
|
|
366
|
+
{
|
|
367
|
+
return this._findManyKeyset(tenantId, notes, { cursorColumn: notes.id, after, limit: 50 });
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async listWithAuthor(tenantId: number)
|
|
371
|
+
{
|
|
372
|
+
return this._select(tenantId, { title: notes.title, author: authors.name })
|
|
373
|
+
.from(notes)
|
|
374
|
+
.innerJoin(authors, eq(notes.authorId, authors.id));
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
`listWithAuthor(7)` runs:
|
|
380
|
+
|
|
381
|
+
```sql
|
|
382
|
+
select "notes"."title", "authors"."name"
|
|
383
|
+
from "acme_notes"."notes"
|
|
384
|
+
inner join "acme_notes"."authors"
|
|
385
|
+
on ("notes"."author_id" = "authors"."id" and "authors"."tenant_id" = $1)
|
|
386
|
+
where "notes"."tenant_id" = $2
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
| Method | Tenant handling |
|
|
390
|
+
|--------|-----------------|
|
|
391
|
+
| `_findOne(tenantId, table, where)` | added to WHERE |
|
|
392
|
+
| `_findMany(tenantId, table, options?)` | added to WHERE |
|
|
393
|
+
| `_findManyKeyset(tenantId, table, options)` | added to WHERE |
|
|
394
|
+
| `_count(tenantId, table, where?, { cap? })` | added to WHERE |
|
|
395
|
+
| `_create` / `_createMany(tenantId, table, data)` | written to the tenant column; a different tenant value in `data` throws, and `_createMany` checks every row before writing any |
|
|
396
|
+
| `_upsert(tenantId, table, data, { target, set? })` | written to the tenant column; `target` must include the tenant column, `set` must not change it |
|
|
397
|
+
| `_updateOne` / `_updateMany(tenantId, table, where, data)` | added to WHERE; `data` naming the tenant column throws |
|
|
398
|
+
| `_deleteOne` / `_deleteMany(tenantId, table, where)` | added to WHERE |
|
|
399
|
+
| `_select(tenantId, fields?)` | the `from` table in WHERE, each joined tenant-aware table in its ON clause |
|
|
400
|
+
|
|
401
|
+
- **The tenant id must be a positive safe integer.** Anything else — `0`, `NaN`,
|
|
402
|
+
`undefined`, a string — throws before a query is built.
|
|
403
|
+
- **Every method takes tenant-aware tables only.** An ordinary table throws; it can still be
|
|
404
|
+
joined in `_select`, where it gets no tenant condition.
|
|
405
|
+
- **Joined tables get the condition in ON, not WHERE.** In WHERE, a left join's unmatched
|
|
406
|
+
rows would be filtered out and it would behave as an inner join. `_select` scopes
|
|
407
|
+
`innerJoin` and `leftJoin` (aliases included); `rightJoin`, `fullJoin`, `crossJoin`, the
|
|
408
|
+
lateral joins and set operators (`union`, … — the methods and the standalone functions)
|
|
409
|
+
throw — write those on `this.readDb`.
|
|
410
|
+
- **A view cannot be joined.** A view's columns are its own, so `_select` cannot tell which
|
|
411
|
+
holds the tenant; joining one throws, as `.from(view)` does. Join the underlying table.
|
|
412
|
+
- **The upsert target must include the tenant column.** With `target: [table.id]`, an insert
|
|
413
|
+
could collide with another tenant's row and update it.
|
|
414
|
+
- **Update and delete still need a where of their own,** as in `BaseRepository`: the tenant
|
|
415
|
+
condition alone does not count, so a bare call cannot rewrite or delete a whole tenant.
|
|
416
|
+
- **Index on the tenant column first.** `tenantKey()` cannot create an index (a Drizzle
|
|
417
|
+
column cannot); declare each index in the table's third argument starting with the tenant
|
|
418
|
+
column, e.g. `(tenant_id, id)` for keyset pages.
|
|
419
|
+
- **`this.db` / `this.readDb` remain available and are not scoped,** nor is a subquery you
|
|
420
|
+
pass as a condition or join source. In local and development runs (`NODE_ENV` `local` or
|
|
421
|
+
`development`; an unset `NODE_ENV` is `local`), the database connection logs a warning —
|
|
422
|
+
once per SQL text, with the SQL — when a query reads, updates or deletes a tenant-aware
|
|
423
|
+
table without a condition on its tenant column. It reads SQL text, so it is a development
|
|
424
|
+
aid, not isolation: an alias it cannot resolve, a raw `sql` template or an unusual shape can
|
|
425
|
+
make it miss a query or warn about a scoped one. A failure inside the check never fails the
|
|
426
|
+
query. Other environments attach no logger.
|
|
427
|
+
|
|
428
|
+
---
|
|
429
|
+
|
|
327
430
|
## Where clauses & query options (`query-utils.ts`)
|
|
328
431
|
|
|
329
432
|
Both the helpers and `BaseRepository._*` methods accept the same two `where` forms,
|
package/src/db/schema/README.md
CHANGED
|
@@ -11,7 +11,7 @@ re-exported from `@spfn/core/db`. Import from there.
|
|
|
11
11
|
```typescript
|
|
12
12
|
import {
|
|
13
13
|
id, uuid, timestamps,
|
|
14
|
-
foreignKey, optionalForeignKey,
|
|
14
|
+
foreignKey, optionalForeignKey, tenantKey,
|
|
15
15
|
auditFields, publishingFields, softDelete,
|
|
16
16
|
verificationTimestamp, utcTimestamp, enumText, typedJsonb,
|
|
17
17
|
createSchema, packageNameToSchema, getSchemaInfo,
|
|
@@ -36,6 +36,7 @@ Column helpers (`entity-helper.ts`):
|
|
|
36
36
|
- `timestamps()` — `createdAt` + `updatedAt` (timestamptz, not null, both written by the database clock)
|
|
37
37
|
- `foreignKey(name, reference, options?)` — required FK (bigint, cascade by default)
|
|
38
38
|
- `optionalForeignKey(name, reference, options?)` — nullable FK (set null by default)
|
|
39
|
+
- `tenantKey(name?)` — not-null bigint tenant column that makes a table tenant-aware (`tenant_id` by default)
|
|
39
40
|
- `auditFields()` — `createdBy` + `updatedBy` (text, nullable)
|
|
40
41
|
- `publishingFields()` — `publishedAt` (timestamptz, nullable) + `publishedBy` (text)
|
|
41
42
|
- `softDelete()` — `deletedAt` (timestamptz, nullable) + `deletedBy` (text)
|
|
@@ -141,6 +142,23 @@ categoryId: optionalForeignKey('category', () => cats.id), // category_id BIGINT
|
|
|
141
142
|
- There is **no** `onUpdate` option on these helpers. For `onUpdate` (or FK to a uuid PK),
|
|
142
143
|
use drizzle's table-level `foreignKey({ columns, foreignColumns, onUpdate })` instead.
|
|
143
144
|
|
|
145
|
+
### `tenantKey(name?)`
|
|
146
|
+
|
|
147
|
+
The tenant column of a table that many tenants share: a not-null `bigint` (JS `number`),
|
|
148
|
+
named `tenant_id` unless you pass a name. Declaring it makes the table tenant-aware —
|
|
149
|
+
`TenantRepository` requires it and scopes every query to one tenant (see the
|
|
150
|
+
[db README](../README.md#tenantrepository-tenant--tables-shared-by-many-tenants)). One per
|
|
151
|
+
table; a second throws when the schema module loads. It creates no index — declare indexes
|
|
152
|
+
in the table's third argument, each starting with the tenant column:
|
|
153
|
+
|
|
154
|
+
```typescript
|
|
155
|
+
export const notes = schema.table('notes', {
|
|
156
|
+
id: id(),
|
|
157
|
+
tenantId: tenantKey(),
|
|
158
|
+
title: text('title').notNull(),
|
|
159
|
+
}, (t) => [index('notes_tenant_id_idx').on(t.tenantId, t.id)]);
|
|
160
|
+
```
|
|
161
|
+
|
|
144
162
|
### `auditFields()`, `publishingFields()`, `softDelete()`
|
|
145
163
|
|
|
146
164
|
```typescript
|