@lenso/organization 0.0.0-stage → 0.2.1
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 +184 -2
- package/dist/auth.d.ts +22 -0
- package/dist/auth.js +26 -0
- package/dist/capacity.d.ts +3 -0
- package/dist/contracts.d.ts +96 -0
- package/dist/drizzle/d1.d.ts +3 -0
- package/dist/drizzle/d1.js +45 -0
- package/dist/drizzle/pg.d.ts +3 -0
- package/dist/drizzle/pg.js +60 -0
- package/dist/drizzle/schema-pg.d.ts +58 -0
- package/dist/drizzle/schema-pg.js +6 -0
- package/dist/drizzle/schema-sqlite.d.ts +62 -0
- package/dist/drizzle/schema-sqlite.js +6 -0
- package/dist/drizzle/shared.d.ts +5 -0
- package/dist/index-0ax8jdj3.js +9 -0
- package/dist/index-2bwbvaz5.js +67 -0
- package/dist/index-ar1vwj4s.js +370 -0
- package/dist/index-kdetdk6w.js +39 -0
- package/dist/index-qgnmjsxx.js +15 -0
- package/dist/index-tsx842dx.js +11 -0
- package/dist/index-zjdv6bc6.js +9 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +18 -0
- package/dist/plugin.d.ts +11 -0
- package/dist/plugin.js +22 -0
- package/dist/policy.d.ts +9 -0
- package/dist/service.d.ts +86 -0
- package/migrations/pg/0000_organizations.sql +5 -0
- package/migrations/sqlite/0000_organizations.sql +5 -0
- package/package.json +73 -4
package/README.md
CHANGED
|
@@ -1,3 +1,185 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Organization
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Optional business organizations, memberships and invitations. The root entry is
|
|
4
|
+
an ordinary async service with no runtime dependencies on Lenso, Auth, Drizzle,
|
|
5
|
+
Manage, Tasks or notification providers.
|
|
6
|
+
|
|
7
|
+
## Responsibility boundaries
|
|
8
|
+
|
|
9
|
+
- **Auth** establishes identity and revalidates its proof. A subject is
|
|
10
|
+
`{ realmId, subjectId }`, not a foreign key to a users table or an email address.
|
|
11
|
+
- **Organization** persists relationships: `member`, `admin`, `owner`, and
|
|
12
|
+
invitations.
|
|
13
|
+
- **Authorization** decides whether a verified subject may perform an action
|
|
14
|
+
within a particular organization. `OrganizationAccess.check` is required.
|
|
15
|
+
|
|
16
|
+
An organization owner is not a Console or platform administrator. A shared Auth
|
|
17
|
+
identity may be a business customer without Console admission, or a Console
|
|
18
|
+
member without any business organization. Independent Console SSO can use another
|
|
19
|
+
Auth realm/source; this package does not create a second password system.
|
|
20
|
+
|
|
21
|
+
## Direct service and existing Auth
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { audience } from "@lenso/auth";
|
|
25
|
+
import { createOrganizationService, memberRelationshipPolicy } from "@lenso/organization";
|
|
26
|
+
import { createAuthOrganizationAccess } from "@lenso/organization/auth";
|
|
27
|
+
import { postgresOrganizationStore } from "@lenso/organization/drizzle/pg";
|
|
28
|
+
|
|
29
|
+
// Borrow the application's existing Auth and Drizzle database.
|
|
30
|
+
const access = authentication.for(audience("business:organization"));
|
|
31
|
+
const organizations = createOrganizationService({
|
|
32
|
+
store: postgresOrganizationStore(database),
|
|
33
|
+
access: createAuthOrganizationAccess({
|
|
34
|
+
access,
|
|
35
|
+
policy: memberRelationshipPolicy,
|
|
36
|
+
}),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// evidence comes from the trusted ingress, never organization input JSON.
|
|
40
|
+
const actor = await access.required(evidence);
|
|
41
|
+
const created = await organizations.createOrganization(actor, { name: "Operations" });
|
|
42
|
+
const members = await organizations.listMembers(actor, {
|
|
43
|
+
organizationId: created.value.id,
|
|
44
|
+
});
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The Auth adapter uses `Access.enforce` on every call and CAS retry. Cloned,
|
|
48
|
+
wrong-audience, wrong-instance and revoked actors fail Auth verification.
|
|
49
|
+
`memberRelationshipPolicy` is an **opt-in example policy**, not inherent role
|
|
50
|
+
semantics: members can read the organization/members and leave; admins manage
|
|
51
|
+
ordinary members and invitations; owners can grant roles/ownership and transfer
|
|
52
|
+
their ownership. Creation is allowed to verified subjects under this policy.
|
|
53
|
+
Applications should constrain creation, actor kinds, realms and administrative
|
|
54
|
+
actions according to their own authorization requirements.
|
|
55
|
+
|
|
56
|
+
Cross-organization administrators require an explicit policy in the access
|
|
57
|
+
adapter, independently verified by the application. No global organization list,
|
|
58
|
+
implicit platform override or Console admission rule is provided. All member and
|
|
59
|
+
invitation IDs, lists and totals are checked in the supplied organization scope.
|
|
60
|
+
Policy callbacks receive actual stored relationships, not client-assigned roles.
|
|
61
|
+
Single-invitation actions include the actual scoped invitation and target, without
|
|
62
|
+
the digest. Role changes include the requested role. List actions authorize the
|
|
63
|
+
**whole organization's** list and count, not a filtered per-invitation view;
|
|
64
|
+
policies that permit only individual invitations should deny these list actions.
|
|
65
|
+
|
|
66
|
+
## Thin Lenso registration
|
|
67
|
+
|
|
68
|
+
`createOrganizationPlugin` from `/plugin` accepts:
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import { createOrganizationPlugin } from "@lenso/organization/plugin";
|
|
72
|
+
|
|
73
|
+
const organization = createOrganizationPlugin({
|
|
74
|
+
id: "business-organizations",
|
|
75
|
+
database: databasePlugin,
|
|
76
|
+
access: organizationAccessPlugin,
|
|
77
|
+
store: postgresOrganizationStore, // d1OrganizationStore for D1
|
|
78
|
+
config: { invitationLifetimeMs: 86_400_000 },
|
|
79
|
+
});
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`databasePlugin` and `organizationAccessPlugin` are the **exact installed
|
|
83
|
+
instances**; the latter supplies `OrganizationAccess<Actor>`. Install those
|
|
84
|
+
instances alongside `organization`. No resources are acquired during config
|
|
85
|
+
import, no migrations run at startup, and stopping this plugin does not close
|
|
86
|
+
borrowed Auth/database resources.
|
|
87
|
+
|
|
88
|
+
Factory configuration is validated by `resolveOrganizationConfig`. Applications
|
|
89
|
+
using Lenso Config may resolve these values in their existing configured setup
|
|
90
|
+
and pass them to `createOrganizationService`; this package does not introduce a
|
|
91
|
+
configuration source or hot-reload system.
|
|
92
|
+
|
|
93
|
+
| Option | Default | Allowed |
|
|
94
|
+
| ---------------------- | ------- | ----------------------------------------- |
|
|
95
|
+
| `invitationLifetimeMs` | 7 days | 1 second to 30 days |
|
|
96
|
+
| `maxMembers` | 1000 | 1 to 1000 |
|
|
97
|
+
| `maxInvitations` | 1000 | 1 to 1000, including terminal invitations |
|
|
98
|
+
| `conflictRetries` | 8 | 0 to 32 known CAS misses |
|
|
99
|
+
|
|
100
|
+
## Operations and invitation handling
|
|
101
|
+
|
|
102
|
+
The service exposes organization create/read/update; member list/add/remove/leave;
|
|
103
|
+
role changes; explicit owner grant/transfer; invitation create/read/list/accept/
|
|
104
|
+
revoke/prune. Input objects reject unknown fields. Add and invite always assign
|
|
105
|
+
`member`; ordinary role input cannot assign `owner`. Self-promotion from member
|
|
106
|
+
to admin and self-grant of ownership are rejected even with a permissive checker.
|
|
107
|
+
|
|
108
|
+
`createInvitation(actor, { organizationId, target })` returns a token once in
|
|
109
|
+
`result.value.token`. It contains 256 random bits. Storage contains only a SHA-256
|
|
110
|
+
digest bound to the organization, invitation ID, realm/subject, fixed role and
|
|
111
|
+
deadline. Do not log the token, put it in a URL or expose it in discovery metadata.
|
|
112
|
+
Acceptance requires the exact target identity, organization, invitation ID and
|
|
113
|
+
token. It atomically consumes the pending invitation and adds membership.
|
|
114
|
+
|
|
115
|
+
Replay returns `INVITATION_ACCEPTED`; revoked and expired invitations return
|
|
116
|
+
`INVITATION_REVOKED` and `INVITATION_EXPIRED`. Wrong subject/token/scoped ID returns
|
|
117
|
+
`NOT_FOUND` after access checks. Removing/leaving a membership also revokes its
|
|
118
|
+
pending invitations in the same write. Expiry is derived from storage time;
|
|
119
|
+
expired invitations never grant membership.
|
|
120
|
+
|
|
121
|
+
No email is sent. An application may call its optional Notification adapter
|
|
122
|
+
**after** invitation creation commits. Delivery failure must not roll back or
|
|
123
|
+
replace the invitation fact; retry delivery separately using the existing Tasks
|
|
124
|
+
integration if needed. An uncertain storage write is not automatically retried.
|
|
125
|
+
|
|
126
|
+
Terminal/expired records remain available until explicitly
|
|
127
|
+
`pruneInvitations(actor, { organizationId, before })` removes them. Pruning never
|
|
128
|
+
removes a still-live pending invitation. After pruning, replay returns `NOT_FOUND`.
|
|
129
|
+
|
|
130
|
+
Each mutation returns `{ value, change }`; `change` includes organization ID,
|
|
131
|
+
new version, action and affected subject references, never invitation credentials.
|
|
132
|
+
`organizationMembershipReader(store)` from `/auth` can feed
|
|
133
|
+
`Auth.Access.memberships`, returning role/version or null after removal.
|
|
134
|
+
Authorization caches must include organization + realm + subject and revalidate
|
|
135
|
+
versions or arrange their own invalidation. Return metadata is not an event
|
|
136
|
+
delivery guarantee; no global event platform is installed.
|
|
137
|
+
|
|
138
|
+
## Storage and explicit migrations
|
|
139
|
+
|
|
140
|
+
- PG: `/drizzle/pg`, `/drizzle/schema-pg`, `migrations/pg/0000_organizations.sql`.
|
|
141
|
+
- D1: `/drizzle/d1`, `/drizzle/schema-sqlite`,
|
|
142
|
+
`migrations/sqlite/0000_organizations.sql`.
|
|
143
|
+
|
|
144
|
+
Add the chosen schema to the application's existing Drizzle schema. Incorporate
|
|
145
|
+
the reviewed SQL into **one** application-owned migration history and apply it
|
|
146
|
+
explicitly to an authorized database; do not apply both Drizzle and Wrangler
|
|
147
|
+
migration runners to one D1 store.
|
|
148
|
+
|
|
149
|
+
One row holds each organization's bounded JSON relationship snapshot and version.
|
|
150
|
+
Every mutation performs version CAS. PG locks the row inside a transaction and
|
|
151
|
+
checks the deadline in a fresh statement after waiting. D1 uses a single
|
|
152
|
+
conditional `UPDATE ... RETURNING`, without interactive transactions. Last-owner
|
|
153
|
+
checks and all transfer/member/invitation changes are committed together. A known
|
|
154
|
+
CAS miss rereads and reauthorizes; a storage exception has unknown outcome.
|
|
155
|
+
|
|
156
|
+
This design rewrites the snapshot and serializes writes per organization. It is
|
|
157
|
+
intended for bounded organizations, not unbounded enterprise directories. A
|
|
158
|
+
512,000-byte serialized state limit supplements the count limits. High contention
|
|
159
|
+
can return `CONFLICT`; large organizations need a separately designed normalized
|
|
160
|
+
store. Direct SQL edits bypass service authorization and are not a public API.
|
|
161
|
+
Admission reserves timestamp-growth headroom so revoking many pending invites
|
|
162
|
+
cannot prevent member removal/exit. Lowering configured count limits stops growth,
|
|
163
|
+
but does not block cleanup of already-admitted relationships.
|
|
164
|
+
|
|
165
|
+
## Validation
|
|
166
|
+
|
|
167
|
+
After building existing `@lenso/core` and `@lenso/auth`, run:
|
|
168
|
+
|
|
169
|
+
```sh
|
|
170
|
+
bun run --cwd packages/organization build
|
|
171
|
+
bun run --cwd packages/organization typecheck
|
|
172
|
+
LENSO_REQUIRE_POSTGRES=1 bun run --cwd packages/organization test
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
PG tests create and stop their own private loopback cluster; the required flag
|
|
176
|
+
fails if `postgres`, `initdb` or `pg_ctl` is unavailable. D1 tests use the existing
|
|
177
|
+
installed Miniflare/workerd toolchain with disposable local D1 state, not a SQLite
|
|
178
|
+
shim. Auth fixture tests exercise the real Auth proof boundary but do not validate
|
|
179
|
+
an external identity provider. Local D1 evidence does not validate deployed
|
|
180
|
+
Cloudflare replication. No exactly-once or cross-resource consistency is claimed.
|
|
181
|
+
|
|
182
|
+
The workspace uses the single integration-owned root Bun lockfile.
|
|
183
|
+
Manage/CLI/MCP operations are not automatically registered. If an application
|
|
184
|
+
exposes any, it must explicitly select methods, authenticate trusted entry context
|
|
185
|
+
and apply its organization authorization policy.
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Access, Actor, MembershipReader } from "@lenso/auth";
|
|
2
|
+
import type { AccessRequest, MemberRole, OrganizationAccess, OrganizationStore } from "./contracts";
|
|
3
|
+
/**
|
|
4
|
+
* Borrow the application's audience-bound Auth access. Policy is required:
|
|
5
|
+
* authentication and organization membership are not authorization by themselves.
|
|
6
|
+
*/
|
|
7
|
+
export declare function createAuthOrganizationAccess<R extends string, Evidence, S extends string, A extends string>(options: {
|
|
8
|
+
access: Access<R, Evidence, S, A>;
|
|
9
|
+
policy(principal: Actor<R, S, A>, request: AccessRequest): boolean | Promise<boolean>;
|
|
10
|
+
}): OrganizationAccess<Actor<R, S, A> | null>;
|
|
11
|
+
export interface OrganizationMembership {
|
|
12
|
+
role: MemberRole;
|
|
13
|
+
version: number;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Trusted internal reader for Auth.Access.memberships. No Console admission and
|
|
17
|
+
* no permission grant. Cache keys must include organization, realm and subject;
|
|
18
|
+
* consumers must revalidate versions or explicitly invalidate after each change.
|
|
19
|
+
*/
|
|
20
|
+
export declare function organizationMembershipReader(store: OrganizationStore): MembershipReader<string, string, {
|
|
21
|
+
organizationId: string;
|
|
22
|
+
}, OrganizationMembership>;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import {
|
|
2
|
+
sameSubject2
|
|
3
|
+
} from "./index-kdetdk6w.js";
|
|
4
|
+
|
|
5
|
+
// src/auth.ts
|
|
6
|
+
function createAuthOrganizationAccess(options) {
|
|
7
|
+
return {
|
|
8
|
+
async check(actor, request) {
|
|
9
|
+
const verified = await options.access.enforce(actor, request, ({ principal, resource }) => options.policy(principal, resource));
|
|
10
|
+
return { realmId: verified.realmId, subjectId: verified.subjectId };
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
function organizationMembershipReader(store) {
|
|
15
|
+
return async (subject, resource, context) => {
|
|
16
|
+
context.signal.throwIfAborted();
|
|
17
|
+
const snapshot = await store.read(resource.organizationId);
|
|
18
|
+
context.signal.throwIfAborted();
|
|
19
|
+
const relationship = snapshot?.state.members.find((item) => sameSubject2(item.subject, subject));
|
|
20
|
+
return relationship && snapshot ? { role: relationship.role, version: snapshot.state.organization.version } : null;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export {
|
|
24
|
+
createAuthOrganizationAccess,
|
|
25
|
+
organizationMembershipReader
|
|
26
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/** A business subject reference, structurally compatible with Auth.SubjectRef. */
|
|
2
|
+
export interface SubjectRef {
|
|
3
|
+
readonly realmId: string;
|
|
4
|
+
readonly subjectId: string;
|
|
5
|
+
}
|
|
6
|
+
export type MemberRole = "member" | "admin" | "owner";
|
|
7
|
+
export interface Member {
|
|
8
|
+
subject: SubjectRef;
|
|
9
|
+
role: MemberRole;
|
|
10
|
+
joinedAt: number;
|
|
11
|
+
}
|
|
12
|
+
export interface Organization {
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
version: number;
|
|
16
|
+
createdAt: number;
|
|
17
|
+
updatedAt: number;
|
|
18
|
+
}
|
|
19
|
+
export interface Invitation {
|
|
20
|
+
id: string;
|
|
21
|
+
organizationId: string;
|
|
22
|
+
target: SubjectRef;
|
|
23
|
+
role: "member";
|
|
24
|
+
createdBy: SubjectRef;
|
|
25
|
+
createdAt: number;
|
|
26
|
+
expiresAt: number;
|
|
27
|
+
status: "pending" | "accepted" | "revoked" | "expired";
|
|
28
|
+
resolvedAt: number | null;
|
|
29
|
+
}
|
|
30
|
+
export interface StoredInvitation extends Omit<Invitation, "status"> {
|
|
31
|
+
status: "pending" | "accepted" | "revoked";
|
|
32
|
+
tokenDigest: string;
|
|
33
|
+
}
|
|
34
|
+
/** One CAS domain: every relationship change increments the organization version. */
|
|
35
|
+
export interface OrganizationState {
|
|
36
|
+
organization: Organization;
|
|
37
|
+
members: Member[];
|
|
38
|
+
invitations: StoredInvitation[];
|
|
39
|
+
}
|
|
40
|
+
export interface OrganizationSnapshot {
|
|
41
|
+
state: OrganizationState;
|
|
42
|
+
/** Storage-authoritative milliseconds, used for invitation expiry. */
|
|
43
|
+
observedAt: number;
|
|
44
|
+
}
|
|
45
|
+
export interface OrganizationStore {
|
|
46
|
+
create(state: OrganizationState): Promise<void>;
|
|
47
|
+
read(organizationId: string): Promise<OrganizationSnapshot | null>;
|
|
48
|
+
/**
|
|
49
|
+
* Atomically replace only at expectedVersion. If validUntil is supplied, check
|
|
50
|
+
* storage time strictly before it at the write boundary, including lock waits.
|
|
51
|
+
* False means no write. An exception means unknown outcome and MUST NOT retry.
|
|
52
|
+
*/
|
|
53
|
+
compareAndSwap(expectedVersion: number, next: OrganizationState, validUntil?: number): Promise<boolean>;
|
|
54
|
+
}
|
|
55
|
+
export type OrganizationAction = "organization.create" | "organization.read" | "organization.update" | "member.list" | "member.add" | "member.remove" | "member.leave" | "member.role" | "owner.grant" | "owner.transfer" | "invitation.create" | "invitation.read" | "invitation.list" | "invitation.accept" | "invitation.revoke" | "invitation.prune";
|
|
56
|
+
export interface AccessRequest {
|
|
57
|
+
action: OrganizationAction;
|
|
58
|
+
organizationId: string | null;
|
|
59
|
+
/** Never includes invitation digests. Null on create or missing organization. */
|
|
60
|
+
organization: Organization | null;
|
|
61
|
+
members: readonly Member[];
|
|
62
|
+
target?: SubjectRef;
|
|
63
|
+
requestedRole?: MemberRole;
|
|
64
|
+
invitationId?: string;
|
|
65
|
+
/** Actual scoped invitation, stripped of its digest; null when missing. */
|
|
66
|
+
invitation?: Invitation | null;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Trusted integration boundary, not business JSON. Must revalidate identity,
|
|
70
|
+
* organization scope and action permission each call (including CAS retries).
|
|
71
|
+
* Cross-organization administration is permitted only by an explicit policy here.
|
|
72
|
+
*/
|
|
73
|
+
export interface OrganizationAccess<Actor> {
|
|
74
|
+
check(actor: Actor, request: AccessRequest): Promise<SubjectRef>;
|
|
75
|
+
}
|
|
76
|
+
export interface OrganizationConfig {
|
|
77
|
+
invitationLifetimeMs: number;
|
|
78
|
+
maxMembers: number;
|
|
79
|
+
maxInvitations: number;
|
|
80
|
+
conflictRetries: number;
|
|
81
|
+
}
|
|
82
|
+
export interface MembershipChange {
|
|
83
|
+
organizationId: string;
|
|
84
|
+
version: number;
|
|
85
|
+
action: OrganizationAction;
|
|
86
|
+
subjects: SubjectRef[];
|
|
87
|
+
}
|
|
88
|
+
export interface MutationResult<T> {
|
|
89
|
+
value: T;
|
|
90
|
+
change: MembershipChange;
|
|
91
|
+
}
|
|
92
|
+
export type OrganizationErrorCode = "INVALID_INPUT" | "FORBIDDEN" | "NOT_FOUND" | "LAST_OWNER" | "ALREADY_MEMBER" | "INVITATION_ACCEPTED" | "INVITATION_REVOKED" | "INVITATION_EXPIRED" | "CONFLICT" | "CAPACITY";
|
|
93
|
+
export declare class OrganizationError extends Error {
|
|
94
|
+
readonly code: OrganizationErrorCode;
|
|
95
|
+
constructor(code: OrganizationErrorCode);
|
|
96
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { DrizzleD1Database } from "drizzle-orm/d1";
|
|
2
|
+
import type { OrganizationStore } from "../contracts";
|
|
3
|
+
export declare function d1OrganizationStore<TSchema extends Record<string, unknown> = Record<string, unknown>>(db: DrizzleD1Database<TSchema>): OrganizationStore;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import {
|
|
2
|
+
organizations3
|
|
3
|
+
} from "../index-0ax8jdj3.js";
|
|
4
|
+
import {
|
|
5
|
+
assertValidVersion,
|
|
6
|
+
validateState,
|
|
7
|
+
encodeState,
|
|
8
|
+
decodeSnapshot
|
|
9
|
+
} from "../index-2bwbvaz5.js";
|
|
10
|
+
|
|
11
|
+
// src/drizzle/d1.ts
|
|
12
|
+
import { and, eq, sql } from "drizzle-orm";
|
|
13
|
+
function d1OrganizationStore(db) {
|
|
14
|
+
return {
|
|
15
|
+
async create(state) {
|
|
16
|
+
await db.insert(organizations3).values({
|
|
17
|
+
id: state.organization.id,
|
|
18
|
+
version: state.organization.version,
|
|
19
|
+
state: encodeState(state)
|
|
20
|
+
}).run();
|
|
21
|
+
},
|
|
22
|
+
async read(id) {
|
|
23
|
+
const [row] = await db.select({
|
|
24
|
+
id: organizations3.id,
|
|
25
|
+
version: organizations3.version,
|
|
26
|
+
state: organizations3.state,
|
|
27
|
+
observedAt: sql`CAST(unixepoch('subsec') * 1000 AS INTEGER)`
|
|
28
|
+
}).from(organizations3).where(eq(organizations3.id, id)).limit(1).all();
|
|
29
|
+
return row ? decodeSnapshot(row.id, row.version, row.state, Number(row.observedAt)) : null;
|
|
30
|
+
},
|
|
31
|
+
async compareAndSwap(expectedVersion, next, validUntil) {
|
|
32
|
+
assertValidVersion(expectedVersion, "expected version");
|
|
33
|
+
validateState(next);
|
|
34
|
+
if (next.organization.version !== expectedVersion + 1)
|
|
35
|
+
throw new Error("Invalid organization version transition");
|
|
36
|
+
if (validUntil !== undefined && !Number.isSafeInteger(validUntil))
|
|
37
|
+
throw new Error("Invalid organization validity deadline");
|
|
38
|
+
const rows = await db.update(organizations3).set({ version: next.organization.version, state: encodeState(next) }).where(and(eq(organizations3.id, next.organization.id), eq(organizations3.version, expectedVersion), validUntil === undefined ? sql`1 = 1` : sql`CAST(unixepoch('subsec') * 1000 AS INTEGER) < ${validUntil}`)).returning({ id: organizations3.id }).all();
|
|
39
|
+
return rows.length > 0;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export {
|
|
44
|
+
d1OrganizationStore
|
|
45
|
+
};
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core";
|
|
2
|
+
import type { OrganizationStore } from "../contracts";
|
|
3
|
+
export declare function postgresOrganizationStore<TSchema extends Record<string, unknown> = Record<string, unknown>, TResult extends PgQueryResultHKT = PgQueryResultHKT>(db: PgDatabase<TResult, TSchema>): OrganizationStore;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import {
|
|
2
|
+
organizations2
|
|
3
|
+
} from "../index-zjdv6bc6.js";
|
|
4
|
+
import {
|
|
5
|
+
assertValidVersion,
|
|
6
|
+
validateState,
|
|
7
|
+
encodeState,
|
|
8
|
+
decodeSnapshot
|
|
9
|
+
} from "../index-2bwbvaz5.js";
|
|
10
|
+
|
|
11
|
+
// src/drizzle/pg.ts
|
|
12
|
+
import { and, eq, sql } from "drizzle-orm";
|
|
13
|
+
function postgresOrganizationStore(db) {
|
|
14
|
+
return {
|
|
15
|
+
async create(state) {
|
|
16
|
+
const encoded = encodeState(state);
|
|
17
|
+
await db.insert(organizations2).values({
|
|
18
|
+
id: state.organization.id,
|
|
19
|
+
version: state.organization.version,
|
|
20
|
+
state: encoded
|
|
21
|
+
});
|
|
22
|
+
},
|
|
23
|
+
async read(id) {
|
|
24
|
+
const [row] = await db.select({
|
|
25
|
+
id: organizations2.id,
|
|
26
|
+
version: organizations2.version,
|
|
27
|
+
state: organizations2.state,
|
|
28
|
+
observedAt: sql`floor(extract(epoch from clock_timestamp()) * 1000)::bigint`
|
|
29
|
+
}).from(organizations2).where(eq(organizations2.id, id)).limit(1);
|
|
30
|
+
return row ? decodeSnapshot(row.id, row.version, row.state, Number(row.observedAt)) : null;
|
|
31
|
+
},
|
|
32
|
+
async compareAndSwap(expectedVersion, next, validUntil) {
|
|
33
|
+
assertValidVersion(expectedVersion, "expected version");
|
|
34
|
+
validateState(next);
|
|
35
|
+
if (next.organization.version !== expectedVersion + 1) {
|
|
36
|
+
throw new Error("Invalid organization version transition");
|
|
37
|
+
}
|
|
38
|
+
const encoded = encodeState(next);
|
|
39
|
+
return db.transaction(async (tx) => {
|
|
40
|
+
const locked = await tx.select({ id: organizations2.id }).from(organizations2).where(eq(organizations2.id, next.organization.id)).for("update");
|
|
41
|
+
if (!locked.length)
|
|
42
|
+
return false;
|
|
43
|
+
const conditions = [
|
|
44
|
+
eq(organizations2.id, next.organization.id),
|
|
45
|
+
eq(organizations2.version, expectedVersion)
|
|
46
|
+
];
|
|
47
|
+
if (validUntil !== undefined) {
|
|
48
|
+
if (!Number.isSafeInteger(validUntil))
|
|
49
|
+
throw new Error("Invalid organization validity deadline");
|
|
50
|
+
conditions.push(sql`floor(extract(epoch from clock_timestamp()) * 1000)::bigint < ${validUntil}::bigint`);
|
|
51
|
+
}
|
|
52
|
+
const rows = await tx.update(organizations2).set({ version: next.organization.version, state: encoded }).where(and(...conditions)).returning({ id: organizations2.id });
|
|
53
|
+
return rows.length > 0;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
export {
|
|
59
|
+
postgresOrganizationStore
|
|
60
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export declare const organizations: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
|
2
|
+
name: "organizations";
|
|
3
|
+
schema: undefined;
|
|
4
|
+
columns: {
|
|
5
|
+
id: import("drizzle-orm/pg-core").PgColumn<{
|
|
6
|
+
name: "id";
|
|
7
|
+
tableName: "organizations";
|
|
8
|
+
dataType: "string";
|
|
9
|
+
columnType: "PgText";
|
|
10
|
+
data: string;
|
|
11
|
+
driverParam: string;
|
|
12
|
+
notNull: true;
|
|
13
|
+
hasDefault: false;
|
|
14
|
+
isPrimaryKey: true;
|
|
15
|
+
isAutoincrement: false;
|
|
16
|
+
hasRuntimeDefault: false;
|
|
17
|
+
enumValues: [string, ...string[]];
|
|
18
|
+
baseColumn: never;
|
|
19
|
+
identity: undefined;
|
|
20
|
+
generated: undefined;
|
|
21
|
+
}, {}, {}>;
|
|
22
|
+
version: import("drizzle-orm/pg-core").PgColumn<{
|
|
23
|
+
name: "version";
|
|
24
|
+
tableName: "organizations";
|
|
25
|
+
dataType: "number";
|
|
26
|
+
columnType: "PgBigInt53";
|
|
27
|
+
data: number;
|
|
28
|
+
driverParam: string | number;
|
|
29
|
+
notNull: true;
|
|
30
|
+
hasDefault: false;
|
|
31
|
+
isPrimaryKey: false;
|
|
32
|
+
isAutoincrement: false;
|
|
33
|
+
hasRuntimeDefault: false;
|
|
34
|
+
enumValues: undefined;
|
|
35
|
+
baseColumn: never;
|
|
36
|
+
identity: undefined;
|
|
37
|
+
generated: undefined;
|
|
38
|
+
}, {}, {}>;
|
|
39
|
+
state: import("drizzle-orm/pg-core").PgColumn<{
|
|
40
|
+
name: "state";
|
|
41
|
+
tableName: "organizations";
|
|
42
|
+
dataType: "string";
|
|
43
|
+
columnType: "PgText";
|
|
44
|
+
data: string;
|
|
45
|
+
driverParam: string;
|
|
46
|
+
notNull: true;
|
|
47
|
+
hasDefault: false;
|
|
48
|
+
isPrimaryKey: false;
|
|
49
|
+
isAutoincrement: false;
|
|
50
|
+
hasRuntimeDefault: false;
|
|
51
|
+
enumValues: [string, ...string[]];
|
|
52
|
+
baseColumn: never;
|
|
53
|
+
identity: undefined;
|
|
54
|
+
generated: undefined;
|
|
55
|
+
}, {}, {}>;
|
|
56
|
+
};
|
|
57
|
+
dialect: 'pg';
|
|
58
|
+
}>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export declare const organizations: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
|
|
2
|
+
name: "organizations";
|
|
3
|
+
schema: undefined;
|
|
4
|
+
columns: {
|
|
5
|
+
id: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
6
|
+
name: "id";
|
|
7
|
+
tableName: "organizations";
|
|
8
|
+
dataType: "string";
|
|
9
|
+
columnType: "SQLiteText";
|
|
10
|
+
data: string;
|
|
11
|
+
driverParam: string;
|
|
12
|
+
notNull: true;
|
|
13
|
+
hasDefault: false;
|
|
14
|
+
isPrimaryKey: true;
|
|
15
|
+
isAutoincrement: false;
|
|
16
|
+
hasRuntimeDefault: false;
|
|
17
|
+
enumValues: [string, ...string[]];
|
|
18
|
+
baseColumn: never;
|
|
19
|
+
identity: undefined;
|
|
20
|
+
generated: undefined;
|
|
21
|
+
}, {}, {
|
|
22
|
+
length: number | undefined;
|
|
23
|
+
}>;
|
|
24
|
+
version: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
25
|
+
name: "version";
|
|
26
|
+
tableName: "organizations";
|
|
27
|
+
dataType: "number";
|
|
28
|
+
columnType: "SQLiteInteger";
|
|
29
|
+
data: number;
|
|
30
|
+
driverParam: number;
|
|
31
|
+
notNull: true;
|
|
32
|
+
hasDefault: false;
|
|
33
|
+
isPrimaryKey: false;
|
|
34
|
+
isAutoincrement: false;
|
|
35
|
+
hasRuntimeDefault: false;
|
|
36
|
+
enumValues: undefined;
|
|
37
|
+
baseColumn: never;
|
|
38
|
+
identity: undefined;
|
|
39
|
+
generated: undefined;
|
|
40
|
+
}, {}, {}>;
|
|
41
|
+
state: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
42
|
+
name: "state";
|
|
43
|
+
tableName: "organizations";
|
|
44
|
+
dataType: "string";
|
|
45
|
+
columnType: "SQLiteText";
|
|
46
|
+
data: string;
|
|
47
|
+
driverParam: string;
|
|
48
|
+
notNull: true;
|
|
49
|
+
hasDefault: false;
|
|
50
|
+
isPrimaryKey: false;
|
|
51
|
+
isAutoincrement: false;
|
|
52
|
+
hasRuntimeDefault: false;
|
|
53
|
+
enumValues: [string, ...string[]];
|
|
54
|
+
baseColumn: never;
|
|
55
|
+
identity: undefined;
|
|
56
|
+
generated: undefined;
|
|
57
|
+
}, {}, {
|
|
58
|
+
length: number | undefined;
|
|
59
|
+
}>;
|
|
60
|
+
};
|
|
61
|
+
dialect: 'sqlite';
|
|
62
|
+
}>;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { OrganizationSnapshot, OrganizationState } from "../contracts";
|
|
2
|
+
export declare function assertValidVersion(value: number, label: string): void;
|
|
3
|
+
export declare function validateState(state: OrganizationState): void;
|
|
4
|
+
export declare function encodeState(state: OrganizationState): string;
|
|
5
|
+
export declare function decodeSnapshot(id: string, version: number, encoded: string, observedAt: number): OrganizationSnapshot;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// src/drizzle/schema-sqlite.ts
|
|
2
|
+
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
3
|
+
var organizations3 = sqliteTable("organizations", {
|
|
4
|
+
id: text("id").primaryKey(),
|
|
5
|
+
version: integer("version").notNull(),
|
|
6
|
+
state: text("state").notNull()
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export { organizations3 };
|