@shaferllc/keel 0.81.0 → 0.81.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/llms-full.txt CHANGED
@@ -8258,6 +8258,53 @@ await db("sessions").where("expires_at", "<", now).delete();
8258
8258
  **Notes:** with no `where`, empties the table. There's no soft-delete here — pair
8259
8259
  with a `deleted_at` column and `whereNull` if you want one.
8260
8260
 
8261
+ #### `whereColumn(first, operator?, second)` · `whereRaw(sql, bindings?)`
8262
+
8263
+ Compare two columns (no binding) or add a raw WHERE fragment with its own
8264
+ bindings. `whereColumn("updated_at", ">", "created_at")`; `whereRaw("score >= ?", [10])`.
8265
+
8266
+ #### `join(table, first, operator?, second)` · `leftJoin(...)`
8267
+
8268
+ Add an `INNER JOIN` / `LEFT JOIN` on an equality (or the given operator).
8269
+ Included in `get`, `count`, and aggregates. Qualify ambiguous columns
8270
+ (`"posts.user_id"`).
8271
+
8272
+ #### `groupBy(...columns)` · `having(column, operator?, value)` · `distinct()`
8273
+
8274
+ `GROUP BY`, a bound `HAVING` predicate, and `SELECT DISTINCT`.
8275
+
8276
+ #### `orderByRaw(sql)` · `when(condition, then, otherwise?)`
8277
+
8278
+ A raw `ORDER BY` fragment; and conditional building — `then(query, value)` runs
8279
+ only when `condition` is truthy, else `otherwise`.
8280
+
8281
+ #### `increment(column, amount?, extra?)` · `decrement(column, amount?, extra?)`
8282
+
8283
+ `increment(column: string, amount = 1, extra: Row = {}): Promise<WriteResult>`
8284
+
8285
+ Atomically `column = column ± amount` on matching rows, optionally setting other
8286
+ columns in the same statement. Scope with `where`.
8287
+
8288
+ #### `upsert(rows, uniqueBy, update?)`
8289
+
8290
+ `upsert(rows: Row | Row[], uniqueBy: string[], update?: string[]): Promise<WriteResult>`
8291
+
8292
+ Insert rows, updating `update` columns (default: all non-unique) on a conflict
8293
+ against `uniqueBy`. Dialect-aware: `ON CONFLICT … DO UPDATE` (sqlite/postgres) or
8294
+ `ON DUPLICATE KEY UPDATE` (mysql).
8295
+
8296
+ #### `insertOrIgnore(rows)`
8297
+
8298
+ Insert one or more rows, skipping any that violate a unique constraint
8299
+ (`INSERT OR IGNORE` / `INSERT IGNORE` / `ON CONFLICT DO NOTHING`).
8300
+
8301
+ #### `chunk(size, callback)`
8302
+
8303
+ `chunk(size: number, callback: (rows: T[]) => void | boolean | Promise<void | boolean>): Promise<void>`
8304
+
8305
+ Process results a page at a time so a large table never loads at once. Return
8306
+ `false` from the callback to stop early. Pair with `orderBy` for a stable order.
8307
+
8261
8308
  ### Interfaces & types
8262
8309
 
8263
8310
  #### `Connection`
@@ -13211,11 +13258,10 @@ already gone — the typical `down()` for a `createTable`.
13211
13258
 
13212
13259
  `raw(sql: string, bindings?: unknown[]): Promise<void>`
13213
13260
 
13214
- Runs arbitrary SQL through the connection — the escape hatch for indexes, foreign
13215
- keys, and `ALTER TABLE`.
13261
+ Runs arbitrary SQL through the connection — the escape hatch for anything the
13262
+ builders don't cover.
13216
13263
 
13217
13264
  ```ts
13218
- await schema.raw("CREATE INDEX idx_posts_user ON posts (user_id)");
13219
13265
  await schema.raw("UPDATE users SET active = ? WHERE active IS NULL", [true]);
13220
13266
  ```
13221
13267
 
@@ -13223,6 +13269,22 @@ await schema.raw("UPDATE users SET active = ? WHERE active IS NULL", [true]);
13223
13269
  `raw()` does **not** rewrite `?` to `$n`, so pass `$1, $2, …` yourself on the
13224
13270
  `postgres` dialect.
13225
13271
 
13272
+ #### `alterTable(name, build)`
13273
+
13274
+ `alterTable(name: string, build: (table: AlterTableBuilder) => void): Promise<void>`
13275
+
13276
+ Alter an existing table — the callback gets an [`AlterTableBuilder`](#altertablebuilder)
13277
+ for adding, renaming, and dropping columns and indexes. Emits one dialect-aware
13278
+ statement per operation, ordered so a dropped index precedes its column.
13279
+
13280
+ ```ts
13281
+ await schema.alterTable("users", (t) => {
13282
+ t.string("phone").nullable();
13283
+ t.renameColumn("name", "full_name");
13284
+ t.dropColumn("legacy");
13285
+ });
13286
+ ```
13287
+
13226
13288
  ### `TableBuilder`
13227
13289
 
13228
13290
  Describes a table's columns. **You get one from the `createTable` callback** — it
@@ -13339,6 +13401,27 @@ t.toCreateSql("users", "postgres");
13339
13401
  // CREATE TABLE users (id SERIAL PRIMARY KEY, email VARCHAR(255) NOT NULL)
13340
13402
  ```
13341
13403
 
13404
+ #### `index(columns, name?)` / `uniqueIndex(columns, name?)`
13405
+
13406
+ Add a (possibly composite) index or unique index; `columns` is a name or array.
13407
+ Emitted as `CREATE [UNIQUE] INDEX` after the table. Auto-named unless `name` is
13408
+ given.
13409
+
13410
+ ```ts
13411
+ t.index("email");
13412
+ t.uniqueIndex(["team_id", "slug"]);
13413
+ ```
13414
+
13415
+ #### `foreign(column)`
13416
+
13417
+ `foreign(column: string): ForeignKeyBuilder`
13418
+
13419
+ Add a foreign key, built fluently and emitted inline in the `CREATE TABLE`.
13420
+
13421
+ ```ts
13422
+ t.foreign("team_id").references("id").on("teams").onDelete("cascade");
13423
+ ```
13424
+
13342
13425
  #### `columns`
13343
13426
 
13344
13427
  `readonly columns: Column[]`
@@ -13346,6 +13429,21 @@ t.toCreateSql("users", "postgres");
13346
13429
  The `Column` instances added so far, in declaration order. Read-only inspection
13347
13430
  seam; you rarely touch it.
13348
13431
 
13432
+ ### `AlterTableBuilder`
13433
+
13434
+ From the `alterTable` callback. Column methods (`string`, `integer`, …) **add**
13435
+ columns; plus:
13436
+
13437
+ - `dropColumn(name)` — drop a column.
13438
+ - `renameColumn(from, to)` — rename a column.
13439
+ - `index(columns, name?)` / `uniqueIndex(columns, name?)` — add an index.
13440
+ - `dropIndex(name)` — drop an index (runs before column drops).
13441
+
13442
+ ### `ForeignKeyBuilder`
13443
+
13444
+ From `TableBuilder.foreign(column)`. Chainable: `references(column)`, `on(table)`,
13445
+ `onDelete(action)`, `onUpdate(action)`.
13446
+
13349
13447
  ### `Column`
13350
13448
 
13351
13449
  A single column definition. **You get one from a `TableBuilder` method** (`t.string(...)` etc.) — you don't construct it in migrations. Modifier methods
@@ -13657,6 +13755,31 @@ await Post.all(); // only published
13657
13755
  await Post.query().where("author_id", 1).get(); // still only published
13658
13756
  ```
13659
13757
 
13758
+ Scopes **inherit**. A scope declared on a base class constrains every model that
13759
+ extends it — which is what makes a single tenant-scoped base class possible:
13760
+
13761
+ ```ts
13762
+ class TenantModel extends Model {}
13763
+ TenantModel.addGlobalScope("tenant", (q) => q.where("teamId", currentTeamId()));
13764
+
13765
+ class Post extends TenantModel {} // scoped, without repeating yourself
13766
+ ```
13767
+
13768
+ Scopes from several levels all apply, and a subclass overrides an ancestor's scope
13769
+ by reusing its name — the nearest declaration wins.
13770
+
13771
+ ### Escaping a scope
13772
+
13773
+ ```ts
13774
+ await Post.withoutGlobalScope("tenant").get(); // one named scope
13775
+ await Post.withoutGlobalScopes().get(); // all of them
13776
+ ```
13777
+
13778
+ Escaping is deliberately explicit, and worth keeping that way. A query that steps
13779
+ outside a tenancy scope is exactly the thing you want to be able to *find* — so it
13780
+ should be typed out and greppable, never something you arrive at by forgetting a
13781
+ `where`.
13782
+
13660
13783
  A **local scope** is just a static method returning a query — no framework
13661
13784
  feature needed:
13662
13785
 
@@ -13970,6 +14093,55 @@ Rarely called directly — `create`/`save` use it internally.
13970
14093
  const storable = Post.toDatabase({ published: true }); // { published: 1 }
13971
14094
  ```
13972
14095
 
14096
+ #### `Model.with(...names)` · `Model.withCount(...names)`
14097
+
14098
+ Start a [`ModelQuery`](#modelquery) that eager-loads the named relations (dotted
14099
+ paths nest: `"posts.comments"`) or counts them into `<relation>_count`.
14100
+
14101
+ #### `Model.has(name)` · `Model.whereHas(name, constrain?)` · `Model.doesntHave(name, constrain?)`
14102
+
14103
+ Start a `ModelQuery` filtered by relationship existence — has at least one
14104
+ related row, has one matching `constrain(query)`, or has none. `constrain`
14105
+ receives the related-table query builder.
14106
+
14107
+ #### `Model.newQuery()`
14108
+
14109
+ `static newQuery(): ModelQuery<T>`
14110
+
14111
+ The model-aware query behind the sugar above — hydrates rows to models and adds
14112
+ `with`/`withCount`/`whereHas`.
14113
+
14114
+ #### `Model.addGlobalScope(name, scope)`
14115
+
14116
+ `static addGlobalScope(name: string, scope: (query: QueryBuilder) => void): void`
14117
+
14118
+ Register a constraint applied to every query the model builds. Inherited by
14119
+ subclasses; a subclass re-using a name overrides it.
14120
+
14121
+ #### `Model.withTrashed()` · `Model.onlyTrashed()` · `Model.withoutGlobalScope(...names)` · `Model.withoutGlobalScopes()`
14122
+
14123
+ Escape hatches returning a `QueryBuilder`: include (or only) soft-deleted rows,
14124
+ or drop named / all global scopes. Deliberately explicit so an unscoped query is
14125
+ greppable at audit time.
14126
+
14127
+ ### `Model` — lifecycle events
14128
+
14129
+ Register per-class hooks (keyed by the exact class). The `*ing` events are
14130
+ cancelable — a hook returning `false` aborts the operation.
14131
+
14132
+ #### `Model.creating` · `created` · `updating` · `updated` · `saving` · `saved` · `deleting` · `deleted` · `restoring` · `restored` · `retrieved`
14133
+
14134
+ `static <event>(hook: (model: T) => void | boolean | Promise<void | boolean>): void`
14135
+
14136
+ Add a hook for that lifecycle event. `create()` fires `saving`→`creating`→write→
14137
+ `created`→`saved`; a save that updates fires the `updating`/`updated` pair.
14138
+
14139
+ #### `Model.observe(observer)`
14140
+
14141
+ `static observe(observer: Partial<Record<ModelEvent, ModelHook<T>>>): void`
14142
+
14143
+ Attach an observer object — each method named after an event becomes a hook.
14144
+
13973
14145
  ### `Model` — configuration statics
13974
14146
 
13975
14147
  Set these on the subclass to configure it. All have defaults.
@@ -14030,6 +14202,21 @@ class Post extends Model {
14030
14202
  }
14031
14203
  ```
14032
14204
 
14205
+ #### `static hidden` / `static visible` / `static appends`
14206
+
14207
+ `static hidden: string[]` · `static visible: string[]` · `static appends: string[]`
14208
+
14209
+ Shape `toJSON()`: `hidden` strips columns, `visible` is an allowlist that wins,
14210
+ `appends` adds computed attributes (a getter or zero-arg method). All default `[]`.
14211
+
14212
+ #### `static softDeletes` / `static deletedAtColumn`
14213
+
14214
+ `static softDeletes: boolean` (default `false`) · `static deletedAtColumn: string`
14215
+ (default `"deleted_at"`)
14216
+
14217
+ Turn on soft deletes: `delete()` sets the timestamp and a global scope hides
14218
+ trashed rows.
14219
+
14033
14220
  ### `Model` — instance methods
14034
14221
 
14035
14222
  #### `new Model(attributes?)`
@@ -14068,14 +14255,22 @@ still issues the query.
14068
14255
 
14069
14256
  `delete(): Promise<void>`
14070
14257
 
14071
- Deletes the row matching this model's primary key.
14258
+ Deletes the row matching this model's primary key — or, with `static softDeletes`
14259
+ on, sets `deleted_at` instead. Fires `deleting`/`deleted`.
14072
14260
 
14073
14261
  ```ts
14074
14262
  await user.delete();
14075
14263
  ```
14076
14264
 
14077
- **Notes:** keys off the current `primaryKey` value; on a model without one, the
14078
- `where` binds `undefined`. Hard delete only — there's no soft-delete built in.
14265
+ **Notes:** keys off the current `primaryKey` value. See `forceDelete`/`restore`
14266
+ for the soft-delete variants.
14267
+
14268
+ #### `forceDelete()` · `restore()` · `trashed()`
14269
+
14270
+ `forceDelete(): Promise<void>` · `restore(): Promise<this>` · `trashed(): boolean`
14271
+
14272
+ For soft-deletable models: permanently remove the row, clear `deleted_at`
14273
+ (fires `restoring`/`restored`), or test whether it's currently trashed.
14079
14274
 
14080
14275
  #### `fill(attributes)`
14081
14276
 
@@ -14205,6 +14400,52 @@ joined with `_` (User + Role → `role_user`). The pivot keys default to
14205
14400
  `<model>_<primaryKey>`. Reads as two `whereIn` queries (no JOIN), so it stays
14206
14401
  edge-safe.
14207
14402
 
14403
+ #### `morphMany(related, name, localKey?)` · `morphOne(related, name, localKey?)`
14404
+
14405
+ `morphMany<T>(related: ModelClass<T>, name: string, localKey?: string): MorphMany<T>`
14406
+
14407
+ The parent side of a polymorphic relation. Related rows carry `<name>_id` +
14408
+ `<name>_type` (the type stored is this model's class name). `MorphMany` also has
14409
+ `.create(attributes)`, which fills the morph keys.
14410
+
14411
+ ```ts
14412
+ comments() { return this.morphMany(Comment, "commentable"); }
14413
+ ```
14414
+
14415
+ #### `morphTo(name, idColumn?, typeColumn?)`
14416
+
14417
+ `morphTo(name: string, idColumn?: string, typeColumn?: string): MorphTo`
14418
+
14419
+ The owning side — resolves the parent from the stored `<name>_type` (via
14420
+ [`registerMorphType`](#registermorphtypetype-model)) and `<name>_id`. Awaitable;
14421
+ returns the parent model or `null`.
14422
+
14423
+ ```ts
14424
+ commentable() { return this.morphTo("commentable"); }
14425
+ ```
14426
+
14427
+ #### `registerMorphType(type, model)`
14428
+
14429
+ `registerMorphType(type: string, related: ModelClass<Model>): void`
14430
+
14431
+ Register a model under a morph-type string (usually its class name) so `morphTo`
14432
+ can resolve it. Call once at boot for each owner type.
14433
+
14434
+ ### `ModelQuery`
14435
+
14436
+ The model-aware builder returned by `Model.query()`, `Model.newQuery()`, and the
14437
+ `with`/`whereHas`/`withCount` shortcuts. It proxies the query-builder constraint
14438
+ methods (`where`, `orderBy`, `limit`, …) and hydrates results to models, adding:
14439
+
14440
+ - `with(...names)` — eager-load relations; dotted paths nest (`"posts.comments"`).
14441
+ - `withCount(...names)` — add `<relation>_count` to each result.
14442
+ - `has(name)` / `whereHas(name, constrain?)` / `doesntHave(name, constrain?)` —
14443
+ filter by relationship existence.
14444
+ - Terminals `get()`, `first()`, `count()`, `exists()`, `paginate(page?, perPage?)`.
14445
+
14446
+ Existence filters and counts use the same driver-agnostic two-query strategy as
14447
+ the relations (no JOIN). `toBase()` returns the underlying `QueryBuilder`.
14448
+
14208
14449
  ### Relations
14209
14450
 
14210
14451
  You never `new` these — a relationship method (`user.posts()`) returns one. Each
@@ -17790,6 +18031,189 @@ Optional capabilities: `metadata` / `copy` / `move` / `signedUrl` /
17790
18031
 
17791
18032
 
17792
18033
 
18034
+ ---
18035
+
18036
+ <!-- source: docs/teams.md -->
18037
+
18038
+ # Teams
18039
+
18040
+ Multi-tenancy, membership, roles, and invitations — where a row belongs to a team,
18041
+ and one team can never see another's.
18042
+
18043
+ ```ts
18044
+ // bootstrap/providers.ts
18045
+ import { TeamsServiceProvider } from "@shaferllc/keel/teams";
18046
+
18047
+ app.register(TeamsServiceProvider);
18048
+ ```
18049
+
18050
+ ```ts
18051
+ // app/Http/Kernel.ts
18052
+ import { teamContext } from "@shaferllc/keel/teams";
18053
+
18054
+ protected middleware = [sessionMiddleware(), teamContext()];
18055
+ ```
18056
+
18057
+ Then a tenant-owned model is one word:
18058
+
18059
+ ```ts
18060
+ import { TenantModel } from "@shaferllc/keel/teams";
18061
+
18062
+ class Post extends TenantModel {
18063
+ static table = "posts";
18064
+ }
18065
+
18066
+ await Post.all(); // only the current team's posts
18067
+ await Post.create({ title: "Hi" }); // stamped with the current team
18068
+ ```
18069
+
18070
+ ## Isolation is the default, not a habit
18071
+
18072
+ Two halves, and both matter.
18073
+
18074
+ **Reads** are constrained by a global scope on `TenantModel`, so every query the
18075
+ model builds carries the team — including `find()`. Naming another team's row by its
18076
+ id returns `null`, not that row. This is the difference between tenancy and a list
18077
+ filter: a filter you forget on one endpoint is a leak; a scope you never write can't
18078
+ be forgotten.
18079
+
18080
+ **Writes** are stamped by a `creating` hook, so a row cannot be born ownerless and
18081
+ end up visible to everyone (or to no one).
18082
+
18083
+ ## No team means an error, not "everything"
18084
+
18085
+ A queued job, a console command, a webhook, a seeder — none of them run inside a
18086
+ request, so none of them have a team. **A tenant query there throws.**
18087
+
18088
+ ```ts
18089
+ await Post.all();
18090
+ // Error: No team in context, so a tenant-scoped query can't be built safely.
18091
+ // Inside a request, add teamContext() to your middleware.
18092
+ // In a job, command, or seeder, wrap the work: runForTeam(team, () => …).
18093
+ // If it genuinely spans every team, say so: withoutTenant(() => …).
18094
+ ```
18095
+
18096
+ This is the security model, and the alternatives are worse:
18097
+
18098
+ | If no team meant… | Then |
18099
+ | --- | --- |
18100
+ | *unscoped* | every background job sees every tenant's rows — this is how customer A's invoice reaches customer B |
18101
+ | `teamId = NULL` | jobs match nothing, "work" fine, and quietly do nothing for a month |
18102
+ | **an error** | a job that forgot **crashes in development** instead of leaking in production |
18103
+
18104
+ So a job says which team it's for:
18105
+
18106
+ ```ts
18107
+ await runForTeam(team, () => sendInvoices());
18108
+ ```
18109
+
18110
+ ...or says, out loud, that it isn't for one:
18111
+
18112
+ ```ts
18113
+ await withoutTenant(() => Post.withoutGlobalScope(TENANT_SCOPE).get());
18114
+ ```
18115
+
18116
+ Both are named calls you can **grep for at audit time**. That's the point: crossing a
18117
+ tenant boundary should be something you typed, never something you arrived at by
18118
+ forgetting a `where`.
18119
+
18120
+ > Your jobs will crash until each one is wrapped. That friction is the feature — it
18121
+ > is a loud failure in development in exchange for not having a silent one in
18122
+ > production.
18123
+
18124
+ The context lives in `AsyncLocalStorage`, not a module global, so two concurrent
18125
+ requests can't see each other's team.
18126
+
18127
+ ## Teams and membership
18128
+
18129
+ ```ts
18130
+ const team = await createTeam("Acme", user.id); // creator becomes the owner
18131
+
18132
+ await teamsFor(user.id); // the teams a user is in
18133
+ await roleOf(user.id, team.id); // "owner" | "admin" | "member" | null
18134
+ await memberOf(user.id, team.id, "admin");
18135
+ await switchTeam(user.id, team.id); // false if they aren't a member
18136
+ ```
18137
+
18138
+ A user is in a team **if and only if a membership row says so**. `teams.owner_id` is a
18139
+ convenience, not an authorization source.
18140
+
18141
+ `switchTeam()` verifies membership, and so does `teamContext()` on every request —
18142
+ `users.current_team_id` is just a number on a row the user can influence, so it is
18143
+ checked, never trusted. Without that, switching teams would be a matter of writing
18144
+ someone else's id onto your own row.
18145
+
18146
+ `Team` and `Membership` are deliberately **not** tenant-scoped: "which teams am I in?"
18147
+ is a question you have to answer *before* you know which team you're in.
18148
+
18149
+ ## Roles
18150
+
18151
+ `owner` > `admin` > `member`, ordered — an owner can do anything an admin can.
18152
+
18153
+ ```ts
18154
+ router.delete("/posts/:post", …).middleware(requireRole("admin"));
18155
+ ```
18156
+
18157
+ ```ts
18158
+ roleAtLeast("owner", "admin"); // true
18159
+ roleAtLeast("member", "admin"); // false
18160
+ ```
18161
+
18162
+ ## Invitations
18163
+
18164
+ ```ts
18165
+ const { token } = await invite(team.id, "grace@example.com", "admin");
18166
+ await acceptInvitation(token, user.id, user.email);
18167
+
18168
+ await pendingInvitations(team.id);
18169
+ await revokeInvitation(id);
18170
+ ```
18171
+
18172
+ Unlike a password-reset link, an invitation **is** a database row — it has to be
18173
+ listable ("3 pending") and revocable, and you can't revoke a stateless token. Only the
18174
+ token's **hash** is stored, so a database leak doesn't open every pending team.
18175
+
18176
+ The invited address is re-checked on accept, so a **forwarded link doesn't let someone
18177
+ else join** in the invitee's place — which is the interesting attack on an invitation
18178
+ system. Invitations are single-use, expire (72h by default), and re-inviting the same
18179
+ address replaces the outstanding invitation rather than stacking duplicates.
18180
+
18181
+ ## Personal teams
18182
+
18183
+ On by default: every new user gets a team of their own, and a solo user is simply a
18184
+ team of one.
18185
+
18186
+ Worth leaving on even for an app that feels single-user. **Tenancy is not a feature
18187
+ you can add later** — bolting a `team_id` onto a schema that already has customer data
18188
+ means a backfill, a migration on every table, and rewriting every query. Ignoring a
18189
+ team you have costs one unused row. Needing a team you don't have costs a weekend.
18190
+
18191
+ ## Configuration
18192
+
18193
+ ```bash
18194
+ keel vendor:publish --tag teams-config
18195
+ ```
18196
+
18197
+ ```ts
18198
+ export default {
18199
+ userTable: "users",
18200
+ personalTeams: true,
18201
+ invitations: { expiresInHours: 72, url: "/invitations/:token" },
18202
+ };
18203
+ ```
18204
+
18205
+ ## The schema
18206
+
18207
+ | Table | |
18208
+ | --- | --- |
18209
+ | `teams` | name, slug, owner_id |
18210
+ | `team_memberships` | team_id, user_id, role — **unique per (team, user)**, enforced by the database |
18211
+ | `team_invitations` | team_id, email, role, token **hash**, expires_at |
18212
+
18213
+ Plus `current_team_id` on your users table.
18214
+
18215
+
18216
+
17793
18217
  ---
17794
18218
 
17795
18219
  <!-- source: docs/telemetry.md -->
package/llms.txt CHANGED
@@ -60,6 +60,7 @@ Userland imports everything from `@shaferllc/keel/core`.
60
60
  - [Social authentication](https://github.com/shaferllc/keel/blob/main/docs/social-auth.md): "Sign in with GitHub / Google / Discord" — OAuth 2.0, without an SDK.
61
61
  - [Static Files](https://github.com/shaferllc/keel/blob/main/docs/static-files.md): serveStatic() serves files from a directory (default public/) before your routes run.
62
62
  - [Storage](https://github.com/shaferllc/keel/blob/main/docs/storage.md): File storage over a pluggable disk — like the database and mail layers, the core imports no filesystem or SDK, so it runs on Node and the edge.
63
+ - [Teams](https://github.com/shaferllc/keel/blob/main/docs/teams.md): Multi-tenancy, membership, roles, and invitations — where a row belongs to a team, and one team can never see another's.
63
64
  - [Telemetry](https://github.com/shaferllc/keel/blob/main/docs/telemetry.md): Distributed tracing — spans, W3C trace context, and an OTLP exporter — with no SDK.
64
65
  - [Templates](https://github.com/shaferllc/keel/blob/main/docs/templates.md): A string templating engine — {{ }} interpolation and @-prefixed tags for logic, includes, layouts, and components.
65
66
  - [Testing](https://github.com/shaferllc/keel/blob/main/docs/testing.md): Test your app by injecting requests — no server, no port, no network — and asserting on the response.
@@ -115,6 +116,7 @@ Every topic has a runnable, type-checked example:
115
116
  - [Sessions example](https://github.com/shaferllc/keel/blob/main/docs/examples/sessions.ts)
116
117
  - [Static Files example](https://github.com/shaferllc/keel/blob/main/docs/examples/static-files.ts)
117
118
  - [Storage example](https://github.com/shaferllc/keel/blob/main/docs/examples/storage.ts)
119
+ - [Teams example](https://github.com/shaferllc/keel/blob/main/docs/examples/teams.ts)
118
120
  - [Telemetry example](https://github.com/shaferllc/keel/blob/main/docs/examples/telemetry.ts)
119
121
  - [Templates example](https://github.com/shaferllc/keel/blob/main/docs/examples/templates.ts)
120
122
  - [Testing example](https://github.com/shaferllc/keel/blob/main/docs/examples/testing.ts)
@@ -129,3 +131,4 @@ Every topic has a runnable, type-checked example:
129
131
  - [Full text of all docs](https://github.com/shaferllc/keel/blob/main/llms-full.txt): every guide concatenated into one file
130
132
  - [AGENTS.md](https://github.com/shaferllc/keel/blob/main/AGENTS.md): conventions and workflow for AI agents editing a Keel app
131
133
  - [README](https://github.com/shaferllc/keel/blob/main/README.md): project overview
134
+ - [Changelog](https://github.com/shaferllc/keel/blob/main/CHANGELOG.md): release history, newest first
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shaferllc/keel",
3
- "version": "0.81.0",
3
+ "version": "0.81.2",
4
4
  "type": "module",
5
5
  "description": "The house framework for Node.js — a service container, providers, routing, JSX views, and a code-generating console.",
6
6
  "license": "MIT",
@@ -65,6 +65,10 @@
65
65
  "types": "./dist/accounts/index.d.ts",
66
66
  "import": "./dist/accounts/index.js"
67
67
  },
68
+ "./teams": {
69
+ "types": "./dist/teams/index.d.ts",
70
+ "import": "./dist/teams/index.js"
71
+ },
68
72
  "./billing": {
69
73
  "types": "./dist/billing/index.d.ts",
70
74
  "import": "./dist/billing/index.js"
@@ -97,7 +101,7 @@
97
101
  "test:coverage": "node --import tsx --test --experimental-test-coverage --test-coverage-exclude='tests/**' tests/*.test.ts",
98
102
  "build:ai": "tsx scripts/build-ai.ts",
99
103
  "build:watch-ui": "vite build --config src/watch/ui/vite.config.ts",
100
- "build:watch-copy": "mkdir -p dist/watch/ui && cp -R src/watch/ui/dist dist/watch/ui/dist && cp src/watch/watch.config.stub dist/watch/ && cp src/openapi/openapi.config.stub dist/openapi/ && cp src/api/api.config.stub dist/api/ && cp src/billing/billing.config.stub dist/billing/ && cp src/accounts/accounts.config.stub dist/accounts/",
104
+ "build:watch-copy": "mkdir -p dist/watch/ui && cp -R src/watch/ui/dist dist/watch/ui/dist && cp src/watch/watch.config.stub dist/watch/ && cp src/openapi/openapi.config.stub dist/openapi/ && cp src/api/api.config.stub dist/api/ && cp src/billing/billing.config.stub dist/billing/ && cp src/accounts/accounts.config.stub dist/accounts/ && cp src/teams/teams.config.stub dist/teams/",
101
105
  "build": "npm run build:ai && npm run build:watch-ui && rm -rf dist && tsc -p tsconfig.build.json && npm run build:watch-copy",
102
106
  "prepare": "npm run build",
103
107
  "typecheck:tests": "tsc --noEmit -p tsconfig.tests.json",