@porulle/core 0.21.0 → 0.22.0

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.
@@ -1 +1 @@
1
- {"version":3,"file":"actor.d.ts","sourceRoot":"","sources":["../../src/auth/actor.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAmB,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAC1E,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AACxC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAI/C,eAAO,MAAM,kBAAkB,OAAO,CAAC;AACvC,eAAO,MAAM,mBAAmB,qBAAwC,CAAC;AAEzE,eAAO,MAAM,4BAA4B,0JAS/B,CAAC;AAEX,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,cAAc,GAAG,MAAM,EAAE,CAEvE;AAsBD,8EAA8E;AAC9E,wBAAsB,YAAY,CAChC,OAAO,EAAE,OAAO,EAChB,IAAI,EAAE,YAAY,EAClB,MAAM,EAAE,cAAc,EACtB,OAAO,GAAE,OAAsD,GAC9D,OAAO,CAAC,KAAK,GAAG,IAAI,CAAC,CA2EvB"}
1
+ {"version":3,"file":"actor.d.ts","sourceRoot":"","sources":["../../src/auth/actor.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAmB,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAC1E,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AACxC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAmB/C,eAAO,MAAM,kBAAkB,OAAO,CAAC;AACvC,eAAO,MAAM,mBAAmB,qBAAwC,CAAC;AAEzE,eAAO,MAAM,4BAA4B,0JAS/B,CAAC;AAEX,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,cAAc,GAAG,MAAM,EAAE,CAEvE;AAkED,8EAA8E;AAC9E,wBAAsB,YAAY,CAChC,OAAO,EAAE,OAAO,EAChB,IAAI,EAAE,YAAY,EAClB,MAAM,EAAE,cAAc,EACtB,OAAO,GAAE,OAAsD,GAC9D,OAAO,CAAC,KAAK,GAAG,IAAI,CAAC,CAiDvB"}
@@ -31,6 +31,44 @@ function toIsoString(value) {
31
31
  const date = value instanceof Date ? value : typeof value === "string" ? new Date(value) : null;
32
32
  return date !== null && Number.isFinite(date.getTime()) ? date.toISOString() : null;
33
33
  }
34
+ /**
35
+ * The caller's role in one organization, by one indexed read of `member`.
36
+ *
37
+ * This used to go through the organization plugin's endpoints, and that cost six
38
+ * statements instead of one. `getFullOrganization` loads the organization row,
39
+ * its invitations and its ENTIRE member list, then scans in JavaScript for a
40
+ * single membership; `getActiveMemberRole` then looks for the same membership
41
+ * again. Both take `headers` and re-resolve the session from them, so each also
42
+ * re-reads `session` and `user`, and the plugin writes `active_organization_id`
43
+ * back to the session row — a write on the hot path of every GET.
44
+ *
45
+ * For a shopper every one of those is a guaranteed miss: a shopper is not a
46
+ * member of the platform organization and never will be. The member-by-
47
+ * organization scan also grows with the member list, so the platform's busiest
48
+ * request got slower as the platform got bigger.
49
+ *
50
+ * The adapter read below is the same query the plugin ended with, issued once
51
+ * and without re-resolving anything. `findOne` returning null IS the answer for
52
+ * a shopper — one miss, done.
53
+ */
54
+ async function findMembershipRole(auth, userId, organizationId) {
55
+ try {
56
+ const context = await auth.$context;
57
+ const membership = await context?.adapter?.findOne({
58
+ model: "member",
59
+ where: [
60
+ { field: "userId", value: userId },
61
+ { field: "organizationId", value: organizationId },
62
+ ],
63
+ });
64
+ return membership?.role;
65
+ }
66
+ catch {
67
+ // A membership that cannot be read is not a role. Treated as customer, as
68
+ // the plugin-endpoint version was, so this stays a performance change.
69
+ return undefined;
70
+ }
71
+ }
34
72
  /** Resolve a better-auth session and its porulle organization permissions. */
35
73
  export async function resolveActor(headers, auth, config, request = new Request("http://localhost", { headers })) {
36
74
  let session;
@@ -52,32 +90,10 @@ export async function resolveActor(headers, auth, config, request = new Request(
52
90
  const defaultOrgId = config.auth?.defaultOrganizationId ?? DEFAULT_ORG_ID;
53
91
  let role = session.session.activeOrganizationRole;
54
92
  let orgId = session.session.activeOrganizationId;
55
- if (!role && auth.api.getFullOrganization) {
56
- try {
57
- const org = await auth.api.getFullOrganization({
58
- query: { organizationId: orgId ?? defaultOrgId },
59
- headers,
60
- });
61
- if (org?.members) {
62
- const membership = org.members.find((m) => m.userId === session.user.id);
63
- if (membership) {
64
- role = membership.role;
65
- orgId = orgId ?? defaultOrgId;
66
- }
67
- }
68
- }
69
- catch {
70
- // fall through — treat as customer
71
- }
72
- }
73
- if (!role && orgId && auth.api.getActiveMemberRole) {
74
- try {
75
- const roleResult = await auth.api.getActiveMemberRole({ headers });
76
- role = roleResult?.role;
77
- }
78
- catch {
79
- // fall through — treat as customer
80
- }
93
+ if (!role) {
94
+ role = await findMembershipRole(auth, session.user.id, orgId ?? defaultOrgId);
95
+ if (role)
96
+ orgId = orgId ?? defaultOrgId;
81
97
  }
82
98
  if (!orgId && config.auth?.storeResolver) {
83
99
  try {
@@ -13,6 +13,20 @@
13
13
  */
14
14
  import type { DatabaseAdapter } from "../kernel/database/adapter.js";
15
15
  import type { DrizzleDatabase } from "../kernel/database/drizzle-db.js";
16
+ /**
17
+ * Records the SQL statements issued between `start()` and `stop()`.
18
+ *
19
+ * Counting statements is the regression guard for round-trip cost: the queries
20
+ * behind an authenticated request take 0.3 ms of database time between them, so
21
+ * a timing assertion measures the network and tells you nothing you can act on,
22
+ * while a count is stable, fast and names exactly what regressed.
23
+ */
24
+ export interface QueryLog {
25
+ /** Begin recording; clears anything previously recorded. */
26
+ start(): void;
27
+ /** Stop recording and return the statements captured, in order. */
28
+ stop(): string[];
29
+ }
16
30
  /**
17
31
  * Creates a PGlite-backed database adapter for testing.
18
32
  *
@@ -28,5 +42,6 @@ export declare function createPGliteTestAdapter(): Promise<{
28
42
  adapter: DatabaseAdapter;
29
43
  db: DrizzleDatabase;
30
44
  cleanup: () => Promise<void>;
45
+ queryLog: QueryLog;
31
46
  }>;
32
47
  //# sourceMappingURL=create-pglite-adapter.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"create-pglite-adapter.d.ts","sourceRoot":"","sources":["../../src/test-utils/create-pglite-adapter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAMH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,+BAA+B,CAAC;AAMrE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,kCAAkC,CAAC;AA0BxE;;;;;;;;;;GAUG;AACH,wBAAsB,uBAAuB,IAAI,OAAO,CAAC;IACvD,OAAO,EAAE,eAAe,CAAC;IACzB,EAAE,EAAE,eAAe,CAAC;IACpB,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC9B,CAAC,CAqFD"}
1
+ {"version":3,"file":"create-pglite-adapter.d.ts","sourceRoot":"","sources":["../../src/test-utils/create-pglite-adapter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAMH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,+BAA+B,CAAC;AAMrE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,kCAAkC,CAAC;AAExE;;;;;;;GAOG;AACH,MAAM,WAAW,QAAQ;IACvB,4DAA4D;IAC5D,KAAK,IAAI,IAAI,CAAC;IACd,mEAAmE;IACnE,IAAI,IAAI,MAAM,EAAE,CAAC;CAClB;AA0BD;;;;;;;;;;GAUG;AACH,wBAAsB,uBAAuB,IAAI,OAAO,CAAC;IACvD,OAAO,EAAE,eAAe,CAAC;IACzB,EAAE,EAAE,eAAe,CAAC;IACpB,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7B,QAAQ,EAAE,QAAQ,CAAC;CACpB,CAAC,CA6GD"}
@@ -50,8 +50,32 @@ async function pushCoreSchema(db) {
50
50
  export async function createPGliteTestAdapter() {
51
51
  // Create in-memory PGlite instance
52
52
  const pg = new PGlite();
53
+ // Every statement Drizzle issues passes through this logger, and Better Auth's
54
+ // drizzleAdapter shares this same instance — so a recording covers the auth
55
+ // reads too. That is the point: the only honest regression guard for "this
56
+ // request costs N round trips" is a count of the statements, not a stopwatch.
57
+ const recorded = [];
58
+ let recording = false;
59
+ const queryLog = {
60
+ start() {
61
+ recorded.length = 0;
62
+ recording = true;
63
+ },
64
+ stop() {
65
+ recording = false;
66
+ return [...recorded];
67
+ },
68
+ };
53
69
  // Wrap with Drizzle ORM first (pushSchema needs the Drizzle instance)
54
- const db = drizzle(pg, { schema: fullSchema });
70
+ const db = drizzle(pg, {
71
+ schema: fullSchema,
72
+ logger: {
73
+ logQuery(query) {
74
+ if (recording)
75
+ recorded.push(query);
76
+ },
77
+ },
78
+ });
55
79
  // Push core schema via drizzle-kit/api (no migration files needed)
56
80
  // PgliteDatabase<Schema> and DrizzleDatabase share the same Schema type;
57
81
  // the HKT parameter differs (PgliteQueryResultHKT vs PgQueryResultHKT)
@@ -125,5 +149,5 @@ export async function createPGliteTestAdapter() {
125
149
  // Re-insert default org after truncation (CASCADE wipes it)
126
150
  await ensureDefaultOrg(db);
127
151
  }
128
- return { adapter, db, cleanup };
152
+ return { adapter, db, cleanup, queryLog };
129
153
  }
@@ -1,4 +1,5 @@
1
1
  import type { CommerceConfig } from "../config/types.js";
2
+ import type { QueryLog } from "./create-pglite-adapter.js";
2
3
  export declare function createTestConfig(overrides?: Partial<CommerceConfig>): Promise<CommerceConfig>;
3
4
  /**
4
5
  * Creates a test config backed by PGlite (in-memory PostgreSQL).
@@ -14,5 +15,6 @@ export declare function createTestConfig(overrides?: Partial<CommerceConfig>): P
14
15
  export declare function createPGliteTestConfig(overrides?: Partial<CommerceConfig>): Promise<{
15
16
  config: CommerceConfig;
16
17
  cleanup: () => Promise<void>;
18
+ queryLog: QueryLog;
17
19
  }>;
18
20
  //# sourceMappingURL=create-test-config.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"create-test-config.d.ts","sourceRoot":"","sources":["../../src/test-utils/create-test-config.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAgDzD,wBAAsB,gBAAgB,CACpC,SAAS,GAAE,OAAO,CAAC,cAAc,CAAM,GACtC,OAAO,CAAC,cAAc,CAAC,CAqHzB;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,sBAAsB,CAC1C,SAAS,GAAE,OAAO,CAAC,cAAc,CAAM,GACtC,OAAO,CAAC;IAAE,MAAM,EAAE,cAAc,CAAC;IAAC,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAAE,CAAC,CAUnE"}
1
+ {"version":3,"file":"create-test-config.d.ts","sourceRoot":"","sources":["../../src/test-utils/create-test-config.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAGzD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,4BAA4B,CAAC;AA8C3D,wBAAsB,gBAAgB,CACpC,SAAS,GAAE,OAAO,CAAC,cAAc,CAAM,GACtC,OAAO,CAAC,cAAc,CAAC,CAqHzB;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,sBAAsB,CAC1C,SAAS,GAAE,OAAO,CAAC,cAAc,CAAM,GACtC,OAAO,CAAC;IACT,MAAM,EAAE,cAAc,CAAC;IACvB,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7B,QAAQ,EAAE,QAAQ,CAAC;CACpB,CAAC,CAUD"}
@@ -169,10 +169,10 @@ export async function createTestConfig(overrides = {}) {
169
169
  */
170
170
  export async function createPGliteTestConfig(overrides = {}) {
171
171
  const { createPGliteTestAdapter } = await import("./create-pglite-adapter.js");
172
- const { adapter, cleanup } = await createPGliteTestAdapter();
172
+ const { adapter, cleanup, queryLog } = await createPGliteTestAdapter();
173
173
  const config = await createTestConfig({
174
174
  databaseAdapter: adapter,
175
175
  ...overrides,
176
176
  });
177
- return { config, cleanup };
177
+ return { config, cleanup, queryLog };
178
178
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/core",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
package/src/auth/actor.ts CHANGED
@@ -4,6 +4,21 @@ import type { AuthInstance } from "./setup.js";
4
4
  import { DEFAULT_ORG_ID } from "./org.js";
5
5
  import { isCredentialRejection } from "./auth-failure.js";
6
6
 
7
+ /**
8
+ * The slice of Better Auth's internal context this file uses. Declared here
9
+ * rather than imported because `$context` is not on the public `Auth` type; if a
10
+ * future Better Auth removes or renames it, `findMembershipRole` degrades to
11
+ * "no role" rather than throwing, and the membership tests fail loudly.
12
+ */
13
+ interface AuthContextLike {
14
+ adapter?: {
15
+ findOne<T>(query: {
16
+ model: string;
17
+ where: { field: string; value: unknown }[];
18
+ }): Promise<T | null>;
19
+ };
20
+ }
21
+
7
22
  export const AUTH_COOKIE_PREFIX = "uc";
8
23
  export const SESSION_COOKIE_NAME = `${AUTH_COOKIE_PREFIX}.session_token`;
9
24
 
@@ -42,6 +57,50 @@ function toIsoString(value: unknown): string | null {
42
57
  return date !== null && Number.isFinite(date.getTime()) ? date.toISOString() : null;
43
58
  }
44
59
 
60
+ /**
61
+ * The caller's role in one organization, by one indexed read of `member`.
62
+ *
63
+ * This used to go through the organization plugin's endpoints, and that cost six
64
+ * statements instead of one. `getFullOrganization` loads the organization row,
65
+ * its invitations and its ENTIRE member list, then scans in JavaScript for a
66
+ * single membership; `getActiveMemberRole` then looks for the same membership
67
+ * again. Both take `headers` and re-resolve the session from them, so each also
68
+ * re-reads `session` and `user`, and the plugin writes `active_organization_id`
69
+ * back to the session row — a write on the hot path of every GET.
70
+ *
71
+ * For a shopper every one of those is a guaranteed miss: a shopper is not a
72
+ * member of the platform organization and never will be. The member-by-
73
+ * organization scan also grows with the member list, so the platform's busiest
74
+ * request got slower as the platform got bigger.
75
+ *
76
+ * The adapter read below is the same query the plugin ended with, issued once
77
+ * and without re-resolving anything. `findOne` returning null IS the answer for
78
+ * a shopper — one miss, done.
79
+ */
80
+ async function findMembershipRole(
81
+ auth: AuthInstance,
82
+ userId: string,
83
+ organizationId: string,
84
+ ): Promise<string | undefined> {
85
+ try {
86
+ const context = await (
87
+ auth as unknown as { $context?: Promise<AuthContextLike> }
88
+ ).$context;
89
+ const membership = await context?.adapter?.findOne<{ role?: string }>({
90
+ model: "member",
91
+ where: [
92
+ { field: "userId", value: userId },
93
+ { field: "organizationId", value: organizationId },
94
+ ],
95
+ });
96
+ return membership?.role;
97
+ } catch {
98
+ // A membership that cannot be read is not a role. Treated as customer, as
99
+ // the plugin-endpoint version was, so this stays a performance change.
100
+ return undefined;
101
+ }
102
+ }
103
+
45
104
  /** Resolve a better-auth session and its porulle organization permissions. */
46
105
  export async function resolveActor(
47
106
  headers: Headers,
@@ -68,35 +127,9 @@ export async function resolveActor(
68
127
  let role = session.session.activeOrganizationRole as string | undefined;
69
128
  let orgId = session.session.activeOrganizationId as string | null;
70
129
 
71
- if (!role && auth.api.getFullOrganization) {
72
- try {
73
- const org = await auth.api.getFullOrganization({
74
- query: { organizationId: orgId ?? defaultOrgId },
75
- headers,
76
- });
77
- if (org?.members) {
78
- const membership = org.members.find(
79
- (m) => m.userId === session.user.id,
80
- );
81
- if (membership) {
82
- role = membership.role;
83
- orgId = orgId ?? defaultOrgId;
84
- }
85
- }
86
- } catch {
87
- // fall through — treat as customer
88
- }
89
- }
90
-
91
- if (!role && orgId && auth.api.getActiveMemberRole) {
92
- try {
93
- const roleResult = await auth.api.getActiveMemberRole({ headers });
94
- role = (roleResult as Record<string, unknown>)?.role as
95
- | string
96
- | undefined;
97
- } catch {
98
- // fall through — treat as customer
99
- }
130
+ if (!role) {
131
+ role = await findMembershipRole(auth, session.user.id, orgId ?? defaultOrgId);
132
+ if (role) orgId = orgId ?? defaultOrgId;
100
133
  }
101
134
 
102
135
  if (!orgId && config.auth?.storeResolver) {
@@ -24,6 +24,21 @@ import { ensureDefaultOrg } from "../auth/org.js";
24
24
  import * as fullSchema from "../kernel/database/schema.js";
25
25
  import type { DrizzleDatabase } from "../kernel/database/drizzle-db.js";
26
26
 
27
+ /**
28
+ * Records the SQL statements issued between `start()` and `stop()`.
29
+ *
30
+ * Counting statements is the regression guard for round-trip cost: the queries
31
+ * behind an authenticated request take 0.3 ms of database time between them, so
32
+ * a timing assertion measures the network and tells you nothing you can act on,
33
+ * while a count is stable, fast and names exactly what regressed.
34
+ */
35
+ export interface QueryLog {
36
+ /** Begin recording; clears anything previously recorded. */
37
+ start(): void;
38
+ /** Stop recording and return the statements captured, in order. */
39
+ stop(): string[];
40
+ }
41
+
27
42
  // drizzle-kit/api uses CJS internally; createRequire provides ESM compat.
28
43
  const require = createRequire(import.meta.url);
29
44
 
@@ -63,12 +78,37 @@ export async function createPGliteTestAdapter(): Promise<{
63
78
  adapter: DatabaseAdapter;
64
79
  db: DrizzleDatabase;
65
80
  cleanup: () => Promise<void>;
81
+ queryLog: QueryLog;
66
82
  }> {
67
83
  // Create in-memory PGlite instance
68
84
  const pg = new PGlite();
69
85
 
86
+ // Every statement Drizzle issues passes through this logger, and Better Auth's
87
+ // drizzleAdapter shares this same instance — so a recording covers the auth
88
+ // reads too. That is the point: the only honest regression guard for "this
89
+ // request costs N round trips" is a count of the statements, not a stopwatch.
90
+ const recorded: string[] = [];
91
+ let recording = false;
92
+ const queryLog: QueryLog = {
93
+ start() {
94
+ recorded.length = 0;
95
+ recording = true;
96
+ },
97
+ stop() {
98
+ recording = false;
99
+ return [...recorded];
100
+ },
101
+ };
102
+
70
103
  // Wrap with Drizzle ORM first (pushSchema needs the Drizzle instance)
71
- const db = drizzle(pg, { schema: fullSchema });
104
+ const db = drizzle(pg, {
105
+ schema: fullSchema,
106
+ logger: {
107
+ logQuery(query) {
108
+ if (recording) recorded.push(query);
109
+ },
110
+ },
111
+ });
72
112
 
73
113
  // Push core schema via drizzle-kit/api (no migration files needed)
74
114
  // PgliteDatabase<Schema> and DrizzleDatabase share the same Schema type;
@@ -147,5 +187,5 @@ export async function createPGliteTestAdapter(): Promise<{
147
187
  await ensureDefaultOrg(db);
148
188
  }
149
189
 
150
- return { adapter, db, cleanup };
190
+ return { adapter, db, cleanup, queryLog };
151
191
  }
@@ -2,6 +2,7 @@ import { defineConfig } from "../config/define-config.js";
2
2
  import type { CommerceConfig } from "../config/types.js";
3
3
  import { Ok } from "../kernel/result.js";
4
4
  import type { StorageAdapter } from "../modules/media/adapter.js";
5
+ import type { QueryLog } from "./create-pglite-adapter.js";
5
6
 
6
7
  function createInMemoryStorageAdapter(): StorageAdapter {
7
8
  const files = new Map<string, { data: ArrayBuffer; contentType: string }>();
@@ -181,14 +182,18 @@ export async function createTestConfig(
181
182
  */
182
183
  export async function createPGliteTestConfig(
183
184
  overrides: Partial<CommerceConfig> = {},
184
- ): Promise<{ config: CommerceConfig; cleanup: () => Promise<void> }> {
185
+ ): Promise<{
186
+ config: CommerceConfig;
187
+ cleanup: () => Promise<void>;
188
+ queryLog: QueryLog;
189
+ }> {
185
190
  const { createPGliteTestAdapter } = await import("./create-pglite-adapter.js");
186
- const { adapter, cleanup } = await createPGliteTestAdapter();
191
+ const { adapter, cleanup, queryLog } = await createPGliteTestAdapter();
187
192
 
188
193
  const config = await createTestConfig({
189
194
  databaseAdapter: adapter,
190
195
  ...overrides,
191
196
  });
192
197
 
193
- return { config, cleanup };
198
+ return { config, cleanup, queryLog };
194
199
  }