@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spfn/core",
3
- "version": "0.3.0-beta.33",
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), `RepositoryError` (class), `RepositoryDatabase` (type)
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,
@@ -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