@pikku/core 0.12.16 → 0.12.18

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.
Files changed (49) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/function/function-runner.js +22 -1
  3. package/dist/function/index.d.ts +1 -0
  4. package/dist/function/list.types.d.ts +113 -0
  5. package/dist/function/list.types.js +28 -0
  6. package/dist/index.d.ts +1 -0
  7. package/dist/services/in-memory-session-store.d.ts +8 -0
  8. package/dist/services/in-memory-session-store.js +12 -0
  9. package/dist/services/index.d.ts +2 -0
  10. package/dist/services/index.js +1 -0
  11. package/dist/services/session-store.d.ts +6 -0
  12. package/dist/services/session-store.js +1 -0
  13. package/dist/services/user-session-service.d.ts +12 -10
  14. package/dist/services/user-session-service.js +18 -21
  15. package/dist/testing/service-tests.d.ts +2 -0
  16. package/dist/testing/service-tests.js +50 -11
  17. package/dist/types/core.types.d.ts +3 -0
  18. package/dist/wirings/channel/channel-store.d.ts +5 -6
  19. package/dist/wirings/channel/local/local-channel-runner.js +7 -4
  20. package/dist/wirings/channel/serverless/serverless-channel-runner.js +25 -8
  21. package/dist/wirings/cli/cli-runner.js +1 -1
  22. package/dist/wirings/http/http-runner.js +1 -1
  23. package/dist/wirings/mcp/mcp-runner.js +1 -1
  24. package/dist/wirings/scheduler/scheduler-runner.js +1 -1
  25. package/dist/wirings/workflow/pikku-workflow-service.js +0 -1
  26. package/package.json +1 -1
  27. package/src/function/function-runner.ts +36 -1
  28. package/src/function/index.ts +7 -0
  29. package/src/function/list.types.test.ts +122 -0
  30. package/src/function/list.types.ts +121 -0
  31. package/src/index.ts +7 -0
  32. package/src/services/in-memory-session-store.test.ts +58 -0
  33. package/src/services/in-memory-session-store.ts +21 -0
  34. package/src/services/index.ts +2 -0
  35. package/src/services/session-store.ts +9 -0
  36. package/src/services/user-session-service.test.ts +53 -19
  37. package/src/services/user-session-service.ts +21 -22
  38. package/src/testing/service-tests.ts +64 -11
  39. package/src/types/core.types.ts +3 -0
  40. package/src/wirings/channel/channel-store.ts +5 -8
  41. package/src/wirings/channel/local/local-channel-runner.ts +10 -4
  42. package/src/wirings/channel/local/local-eventhub-service.test.ts +0 -5
  43. package/src/wirings/channel/serverless/serverless-channel-runner.ts +30 -9
  44. package/src/wirings/cli/cli-runner.ts +3 -1
  45. package/src/wirings/http/http-runner.ts +3 -1
  46. package/src/wirings/mcp/mcp-runner.ts +3 -1
  47. package/src/wirings/scheduler/scheduler-runner.ts +1 -1
  48. package/src/wirings/workflow/pikku-workflow-service.ts +0 -1
  49. package/tsconfig.tsbuildinfo +1 -1
@@ -34,7 +34,7 @@ export const runChannelConnect = async ({ channelId, channelObject, request, res
34
34
  if (request instanceof Request) {
35
35
  http = createHTTPWire(new PikkuFetchHTTPRequest(request), response);
36
36
  }
37
- const userSession = new PikkuSessionService(channelStore, channelId);
37
+ const userSession = new PikkuSessionService(singletonServices.sessionStore);
38
38
  const { channelConfig, openingData, meta } = await openChannel({
39
39
  channelId,
40
40
  request,
@@ -77,6 +77,11 @@ export const runChannelConnect = async ({ channelId, channelObject, request, res
77
77
  channelMiddlewareMeta: meta.channelMiddleware,
78
78
  });
79
79
  }
80
+ // Store the pikkuUserId mapping after auth middleware has run
81
+ const pikkuUserId = userSession.getPikkuUserId();
82
+ if (pikkuUserId) {
83
+ await channelStore.setPikkuUserId(channelId, pikkuUserId);
84
+ }
80
85
  http?.response?.status(101);
81
86
  }
82
87
  catch (e) {
@@ -98,21 +103,27 @@ export const runChannelDisconnect = async ({ channelId, channelStore, channelHan
98
103
  // there's nothing to disconnect, so we can return early.
99
104
  let channelData;
100
105
  try {
101
- channelData = await channelStore.getChannelAndSession(channelId);
106
+ channelData = await channelStore.getChannel(channelId);
102
107
  }
103
108
  catch {
104
109
  singletonServices.logger.info(`Channel ${channelId} not found during disconnect - already cleaned up`);
105
110
  return;
106
111
  }
107
- const { openingData, channelName, session } = channelData;
112
+ const { openingData, channelName, pikkuUserId } = channelData;
108
113
  const { channel, channelConfig, meta } = getVariablesForChannel({
109
114
  channelId,
110
115
  channelHandlerFactory,
111
116
  openingData,
112
117
  channelName,
113
118
  });
114
- const userSession = new PikkuSessionService(channelStore, channelId);
115
- userSession.setInitial(session);
119
+ const userSession = new PikkuSessionService(singletonServices.sessionStore);
120
+ if (pikkuUserId) {
121
+ userSession.setPikkuUserId(pikkuUserId);
122
+ const session = await singletonServices.sessionStore?.get(pikkuUserId);
123
+ if (session) {
124
+ userSession.setInitial(session);
125
+ }
126
+ }
116
127
  const wire = {
117
128
  channel,
118
129
  ...createMiddlewareSessionWireProps(userSession),
@@ -149,15 +160,21 @@ export const runChannelMessage = async ({ channelId, channelStore, channelHandle
149
160
  const singletonServices = getSingletonServices();
150
161
  const createWireServices = getCreateWireServices();
151
162
  let wireServices;
152
- const { openingData, channelName, session } = await channelStore.getChannelAndSession(channelId);
163
+ const { openingData, channelName, pikkuUserId } = await channelStore.getChannel(channelId);
153
164
  const { channel, channelHandler, channelConfig } = getVariablesForChannel({
154
165
  channelId,
155
166
  channelHandlerFactory,
156
167
  openingData,
157
168
  channelName,
158
169
  });
159
- const userSession = new PikkuSessionService(channelStore, channelId);
160
- userSession.setInitial(session);
170
+ const userSession = new PikkuSessionService(singletonServices.sessionStore);
171
+ if (pikkuUserId) {
172
+ userSession.setPikkuUserId(pikkuUserId);
173
+ const session = await singletonServices.sessionStore?.get(pikkuUserId);
174
+ if (session) {
175
+ userSession.setInitial(session);
176
+ }
177
+ }
161
178
  const wire = {
162
179
  channel,
163
180
  ...createMiddlewareSessionWireProps(userSession),
@@ -224,7 +224,7 @@ export async function runCLICommand({ program, commandPath, data, singletonServi
224
224
  },
225
225
  state: 'open',
226
226
  };
227
- const userSession = new PikkuSessionService();
227
+ const userSession = new PikkuSessionService(singletonServices.sessionStore);
228
228
  const wire = {
229
229
  cli: {
230
230
  program,
@@ -201,9 +201,9 @@ export const createHTTPWire = (request, response) => {
201
201
  * @throws Throws errors like MissingSessionError or ForbiddenError on validation failures.
202
202
  */
203
203
  const executeRoute = async (services, matchedRoute, http, options) => {
204
- const userSession = new PikkuSessionService();
205
204
  const { params, route, meta } = matchedRoute;
206
205
  const { singletonServices, createWireServices, skipUserSession, requestId } = services;
206
+ const userSession = new PikkuSessionService(singletonServices.sessionStore);
207
207
  // Attach URL parameters to the request object
208
208
  http?.request?.setParams(params);
209
209
  // Validate request headers if schema is defined
@@ -103,7 +103,7 @@ async function runMCPPikkuFunc(request, type, name, mcp, pikkuFuncId, { mcp: mcp
103
103
  throw new NotFoundError(`MCP '${type}' PikkuFunction Mapping not found for '${name}'`);
104
104
  }
105
105
  singletonServices.logger.debug(`Running MCP ${type}: ${name}`);
106
- const mcpSessionService = new PikkuSessionService();
106
+ const mcpSessionService = new PikkuSessionService(singletonServices.sessionStore);
107
107
  const wire = {
108
108
  mcp: mcpWire,
109
109
  ...createMiddlewareSessionWireProps(mcpSessionService),
@@ -41,7 +41,7 @@ export async function runScheduledTask({ name, session, traceId, }) {
41
41
  singletonServices.logger;
42
42
  const task = pikkuState(null, 'scheduler', 'tasks').get(name);
43
43
  const meta = pikkuState(null, 'scheduler', 'meta')[name];
44
- const userSession = new PikkuSessionService();
44
+ const userSession = new PikkuSessionService(singletonServices.sessionStore);
45
45
  if (session) {
46
46
  userSession.set(session);
47
47
  }
@@ -425,7 +425,6 @@ export class PikkuWorkflowService {
425
425
  const wire = {
426
426
  workflow: workflowWire,
427
427
  pikkuUserId: run.wire?.pikkuUserId,
428
- session: rpcService.wire?.session,
429
428
  rpc: rpcService.wire?.rpc,
430
429
  };
431
430
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.16",
3
+ "version": "0.12.18",
4
4
  "author": "yasser.fadl@gmail.com",
5
5
  "license": "MIT",
6
6
  "module": "dist/index.js",
@@ -26,15 +26,44 @@ import type {
26
26
  } from './functions.types.js'
27
27
  import { parseVersionedId } from '../version.js'
28
28
  import type { SessionService } from '../services/user-session-service.js'
29
- import { createFunctionSessionWireProps } from '../services/user-session-service.js'
29
+ import {
30
+ PikkuSessionService,
31
+ createFunctionSessionWireProps,
32
+ } from '../services/user-session-service.js'
30
33
  import { ForbiddenError, ReadonlySessionError } from '../errors/errors.js'
31
34
  import {
32
35
  PikkuCredentialWireService,
33
36
  createWireServicesCredentialWireProps,
34
37
  } from '../services/credential-wire-service.js'
38
+ import { defaultPikkuUserIdResolver } from '../services/pikku-user-id.js'
35
39
  import { rpcService } from '../wirings/rpc/rpc-runner.js'
36
40
  import { closeWireServices } from '../utils.js'
37
41
 
42
+ async function resolveSession(
43
+ wire: PikkuWire,
44
+ singletonServices: CoreSingletonServices,
45
+ sessionService?: SessionService<CoreUserSession>
46
+ ): Promise<void> {
47
+ const pikkuUserId = defaultPikkuUserIdResolver(wire)
48
+ if (pikkuUserId) {
49
+ wire.pikkuUserId = pikkuUserId
50
+ if (sessionService instanceof PikkuSessionService) {
51
+ sessionService.setPikkuUserId(pikkuUserId)
52
+ }
53
+ }
54
+
55
+ const { sessionStore } = singletonServices
56
+ if (!sessionStore || !pikkuUserId) return
57
+
58
+ if (!wire.session) {
59
+ const stored = await sessionStore.get(pikkuUserId)
60
+ if (stored) {
61
+ wire.session = stored
62
+ sessionService?.setInitial(stored)
63
+ }
64
+ }
65
+ }
66
+
38
67
  /**
39
68
  * Get or create singleton services for an addon package.
40
69
  * Services are cached in pikkuState to avoid recreation on each call.
@@ -256,6 +285,12 @@ export const runPikkuFunc = async <In = any, Out = any>(
256
285
 
257
286
  // Helper function to run permissions and execute the function
258
287
  const executeFunction = async () => {
288
+ await resolveSession(
289
+ resolvedWire,
290
+ resolvedSingletonServices,
291
+ sessionService
292
+ )
293
+
259
294
  if (sessionService) {
260
295
  resolvedWire.session = sessionService.freezeInitial()
261
296
  resolvedWire.setSession = (s: any) => sessionService.set(s)
@@ -13,3 +13,10 @@ export type {
13
13
  CorePikkuAuthConfig,
14
14
  CorePikkuPermission,
15
15
  } from './functions.types.js'
16
+ export type {
17
+ ListInput,
18
+ ListOutput,
19
+ Filter,
20
+ LeafFilter,
21
+ LeafValue,
22
+ } from './list.types.js'
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Type-level tests for list-function primitives.
3
+ *
4
+ * No runtime assertions — the point is that valid shapes compile and
5
+ * invalid shapes fail with `@ts-expect-error`. The file is excluded
6
+ * from `yarn build` via tsconfig's `**∕*.test.ts` pattern.
7
+ */
8
+
9
+ import type { ListInput, ListOutput, Filter } from './list.types.js'
10
+
11
+ // -- ListInput ---------------------------------------------------------------
12
+
13
+ type SessionFilter = {
14
+ status?: string[]
15
+ therapistId?: string
16
+ uploadedAt?: string
17
+ }
18
+ type SessionSort = 'user' | 'status' | 'uploaded_at'
19
+
20
+ const _basic: ListInput<SessionFilter, SessionSort> = {
21
+ cursor: 'abc',
22
+ limit: 50,
23
+ sort: [
24
+ { column: 'uploaded_at', direction: 'desc' },
25
+ { column: 'status', direction: 'asc' },
26
+ ],
27
+ filter: { status: ['pending'] },
28
+ search: 'sarah',
29
+ }
30
+ void _basic
31
+
32
+ const _empty: ListInput = {}
33
+ void _empty
34
+
35
+ // sort.column must be from the declared union
36
+ // @ts-expect-error — 'created_at' is not in SessionSort
37
+ const _badSort: ListInput<SessionFilter, SessionSort> = {
38
+ sort: [{ column: 'created_at', direction: 'asc' }],
39
+ }
40
+ void _badSort
41
+
42
+ // sort.direction must be 'asc' or 'desc'
43
+ // @ts-expect-error — 'descending' is not a valid direction
44
+ const _badDirection: ListInput<SessionFilter, SessionSort> = {
45
+ sort: [{ column: 'user', direction: 'descending' }],
46
+ }
47
+ void _badDirection
48
+
49
+ // -- ListOutput --------------------------------------------------------------
50
+
51
+ interface Session {
52
+ id: string
53
+ user: string
54
+ status: string
55
+ }
56
+
57
+ const _output: ListOutput<Session> = {
58
+ rows: [{ id: '1', user: 'sarah', status: 'pending' }],
59
+ nextCursor: null,
60
+ totalCount: 42,
61
+ }
62
+ void _output
63
+
64
+ // nextCursor is required (must be string or null, not undefined-only)
65
+ // @ts-expect-error — missing nextCursor
66
+ const _badOutput: ListOutput<Session> = {
67
+ rows: [],
68
+ }
69
+ void _badOutput
70
+
71
+ // -- Filter: leaves ----------------------------------------------------------
72
+
73
+ // Simple equality
74
+ const _leafEq: Filter<SessionFilter> = { therapistId: 'kim' }
75
+ void _leafEq
76
+
77
+ // Array = IN shorthand
78
+ const _leafIn: Filter<SessionFilter> = { status: ['pending', 'processed'] }
79
+ void _leafIn
80
+
81
+ // Operator object
82
+ const _leafOp: Filter<SessionFilter> = {
83
+ uploadedAt: { gt: '2026-04-10', lte: '2026-04-17' },
84
+ }
85
+ void _leafOp
86
+
87
+ // Null = IS NULL
88
+ const _leafNull: Filter<SessionFilter> = { therapistId: null }
89
+ void _leafNull
90
+
91
+ // NOT on a value
92
+ const _leafNot: Filter<SessionFilter> = { therapistId: { not: 'kim' } }
93
+ void _leafNot
94
+
95
+ // -- Filter: AND (array) -----------------------------------------------------
96
+
97
+ const _and: Filter<SessionFilter> = [
98
+ { status: ['pending'] },
99
+ { therapistId: 'kim' },
100
+ { uploadedAt: { gt: '2026-04-10' } },
101
+ ]
102
+ void _and
103
+
104
+ // -- Filter: OR (object with arbitrary label keys) --------------------------
105
+
106
+ const _or: Filter<SessionFilter> = {
107
+ viaKim: { therapistId: 'kim' },
108
+ viaPark: { therapistId: 'park' },
109
+ }
110
+ void _or
111
+
112
+ // -- Filter: nested AND/OR ---------------------------------------------------
113
+
114
+ const _nested: Filter<SessionFilter> = [
115
+ { status: ['pending'] },
116
+ {
117
+ kim: { therapistId: 'kim' },
118
+ park: { therapistId: 'park' },
119
+ },
120
+ { uploadedAt: { gt: '2026-04-10' } },
121
+ ]
122
+ void _nested
@@ -0,0 +1,121 @@
1
+ /**
2
+ * List-function primitives.
3
+ *
4
+ * A "list function" is any Pikku function that returns a paginated
5
+ * collection. Adopting this shape unlocks a shared vocabulary across
6
+ * MCP tools, AI agents, typed RPC clients, and widget libraries — they
7
+ * all reason about cursor, filter, sort, and search uniformly.
8
+ *
9
+ * Under the hood, a list function is still a normal `pikkuFunc`. These
10
+ * types are purely structural constraints; no runtime behaviour change.
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * import { pikkuFunc } from '#pikku'
15
+ * import type { ListInput, ListOutput } from '@pikku/core'
16
+ *
17
+ * export const listSessions = pikkuFunc<
18
+ * ListInput<{ status?: SessionStatus[] }, 'user' | 'status' | 'uploaded_at'>,
19
+ * ListOutput<Session>
20
+ * >({
21
+ * func: async ({ kysely }, input) => {
22
+ * // input.sort / input.filter / input.cursor / input.search are typed
23
+ * return { rows, nextCursor, totalCount }
24
+ * },
25
+ * })
26
+ * ```
27
+ */
28
+
29
+ /**
30
+ * Shape every list function accepts as input.
31
+ *
32
+ * @template F - User-defined filter shape. Each key is a filterable field.
33
+ * @template S - String union of sortable column names.
34
+ */
35
+ export interface ListInput<
36
+ F extends Record<string, unknown> = Record<string, never>,
37
+ S extends string = never,
38
+ > {
39
+ /** Opaque cursor from the previous page's `nextCursor`. */
40
+ cursor?: string
41
+ /** Page size. Server may cap. */
42
+ limit?: number
43
+ /** Ordered sort criteria — first entry is primary. */
44
+ sort?: Array<{ column: S; direction: 'asc' | 'desc' }>
45
+ /** Structured filter tree. See {@link Filter}. */
46
+ filter?: Filter<F>
47
+ /** Unstructured text search across server-configured fields. */
48
+ search?: string
49
+ }
50
+
51
+ /**
52
+ * Shape every list function returns.
53
+ */
54
+ export interface ListOutput<Row> {
55
+ rows: Row[]
56
+ /** Null when no more pages. */
57
+ nextCursor: string | null
58
+ /** Optional — backend may skip when expensive. */
59
+ totalCount?: number
60
+ }
61
+
62
+ /**
63
+ * Leaf predicate value on a single field.
64
+ *
65
+ * Operator keywords (`equals`, `not`, `in`, `notIn`, `gt`, `gte`, `lt`,
66
+ * `lte`, `contains`, `startsWith`, `endsWith`, `mode`) mirror Prisma's
67
+ * vocabulary for dev familiarity. These keys are reserved and cannot be
68
+ * used as user field names in a Filter's `F` type.
69
+ */
70
+ export type LeafValue<T> =
71
+ | T
72
+ | null
73
+ | T[]
74
+ | {
75
+ equals?: T | null
76
+ not?: T | null | LeafValue<T>
77
+ in?: T[]
78
+ notIn?: T[]
79
+ gt?: T
80
+ gte?: T
81
+ lt?: T
82
+ lte?: T
83
+ contains?: string
84
+ startsWith?: string
85
+ endsWith?: string
86
+ mode?: 'sensitive' | 'insensitive'
87
+ }
88
+
89
+ /**
90
+ * A single-key object keyed by a field name from F, value is a leaf
91
+ * predicate. Single-key so the runtime discriminator is unambiguous:
92
+ * multi-key objects are OR groups.
93
+ */
94
+ export type LeafFilter<F extends Record<string, unknown>> = {
95
+ [K in keyof F]: { [Key in K]: LeafValue<F[K]> }
96
+ }[keyof F]
97
+
98
+ /**
99
+ * Recursive filter tree.
100
+ *
101
+ * - `Array<Filter<F>>` — AND of children.
102
+ * - `{ [label: string]: Filter<F> }` — OR of children. Keys are arbitrary
103
+ * labels (unique strings), ignored at evaluation time.
104
+ * - `LeafFilter<F>` — single-key predicate on a declared field.
105
+ *
106
+ * @example "status = pending AND (therapist = kim OR therapist = park) AND uploaded_at > 2026-04-10"
107
+ * ```ts
108
+ * const filter: Filter<{ status: string; therapistId: string; uploadedAt: string }> = [
109
+ * { status: 'pending' },
110
+ * {
111
+ * kim: { therapistId: 'kim' },
112
+ * park: { therapistId: 'park' },
113
+ * },
114
+ * { uploadedAt: { gt: '2026-04-10' } },
115
+ * ]
116
+ * ```
117
+ */
118
+ export type Filter<F extends Record<string, unknown>> =
119
+ | LeafFilter<F>
120
+ | Filter<F>[]
121
+ | { [label: string]: Filter<F> }
package/src/index.ts CHANGED
@@ -59,6 +59,13 @@ export {
59
59
  pikkuApprovalDescription,
60
60
  } from './function/functions.types.js'
61
61
  export { addFunction, getAllFunctionNames } from './function/index.js'
62
+ export type {
63
+ ListInput,
64
+ ListOutput,
65
+ Filter,
66
+ LeafFilter,
67
+ LeafValue,
68
+ } from './function/list.types.js'
62
69
  export { PikkuRequest } from './pikku-request.js'
63
70
  export {
64
71
  getRelativeTimeOffsetFromNow,
@@ -0,0 +1,58 @@
1
+ import { describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { InMemorySessionStore } from './in-memory-session-store.js'
5
+
6
+ describe('InMemorySessionStore', () => {
7
+ test('get returns undefined for unknown user', async () => {
8
+ const store = new InMemorySessionStore()
9
+ const result = await store.get('unknown')
10
+ assert.equal(result, undefined)
11
+ })
12
+
13
+ test('set and get round-trip', async () => {
14
+ const store = new InMemorySessionStore()
15
+ const session = { userId: 'user-1', organizationId: 'org-1' } as any
16
+ await store.set('user-1', session)
17
+
18
+ const result = await store.get('user-1')
19
+ assert.deepEqual(result, session)
20
+ })
21
+
22
+ test('set overwrites previous session', async () => {
23
+ const store = new InMemorySessionStore()
24
+ await store.set('user-1', { userId: 'user-1', role: 'admin' } as any)
25
+ await store.set('user-1', { userId: 'user-1', role: 'member' } as any)
26
+
27
+ const result = await store.get('user-1')
28
+ assert.deepEqual(result, { userId: 'user-1', role: 'member' })
29
+ })
30
+
31
+ test('clear removes session', async () => {
32
+ const store = new InMemorySessionStore()
33
+ await store.set('user-1', { userId: 'user-1' } as any)
34
+ assert.ok(await store.get('user-1'))
35
+
36
+ await store.clear('user-1')
37
+ const result = await store.get('user-1')
38
+ assert.equal(result, undefined)
39
+ })
40
+
41
+ test('clear is no-op for unknown user', async () => {
42
+ const store = new InMemorySessionStore()
43
+ await store.clear('nonexistent')
44
+ })
45
+
46
+ test('users are isolated', async () => {
47
+ const store = new InMemorySessionStore()
48
+ await store.set('user-a', { userId: 'a' } as any)
49
+ await store.set('user-b', { userId: 'b' } as any)
50
+
51
+ assert.deepEqual(await store.get('user-a'), { userId: 'a' })
52
+ assert.deepEqual(await store.get('user-b'), { userId: 'b' })
53
+
54
+ await store.clear('user-a')
55
+ assert.equal(await store.get('user-a'), undefined)
56
+ assert.deepEqual(await store.get('user-b'), { userId: 'b' })
57
+ })
58
+ })
@@ -0,0 +1,21 @@
1
+ import type { CoreUserSession } from '../types/core.types.js'
2
+ import type { SessionStore } from './session-store.js'
3
+
4
+ export class InMemorySessionStore<
5
+ UserSession extends CoreUserSession = CoreUserSession,
6
+ > implements SessionStore<UserSession>
7
+ {
8
+ private sessions = new Map<string, UserSession>()
9
+
10
+ async get(pikkuUserId: string): Promise<UserSession | undefined> {
11
+ return this.sessions.get(pikkuUserId)
12
+ }
13
+
14
+ async set(pikkuUserId: string, session: UserSession): Promise<void> {
15
+ this.sessions.set(pikkuUserId, session)
16
+ }
17
+
18
+ async clear(pikkuUserId: string): Promise<void> {
19
+ this.sessions.delete(pikkuUserId)
20
+ }
21
+ }
@@ -68,6 +68,8 @@ export type {
68
68
  } from './typed-credential-service.js'
69
69
  export type { VariableStatus, VariableMeta } from './typed-variables-service.js'
70
70
  export type { MetaService } from './meta-service.js'
71
+ export type { SessionStore } from './session-store.js'
72
+ export { InMemorySessionStore } from './in-memory-session-store.js'
71
73
  export type {
72
74
  MCPMeta,
73
75
  RPCMetaRecord,
@@ -0,0 +1,9 @@
1
+ import type { CoreUserSession } from '../types/core.types.js'
2
+
3
+ export interface SessionStore<
4
+ UserSession extends CoreUserSession = CoreUserSession,
5
+ > {
6
+ get(pikkuUserId: string): Promise<UserSession | undefined>
7
+ set(pikkuUserId: string, session: UserSession): Promise<void>
8
+ clear(pikkuUserId: string): Promise<void>
9
+ }
@@ -19,17 +19,17 @@ describe('PikkuSessionService', () => {
19
19
  assert.strictEqual(service.get(), undefined)
20
20
  })
21
21
 
22
- test('should mark sessionChanged when set is called', () => {
22
+ test('should mark sessionChanged when set is called', async () => {
23
23
  const service = new PikkuSessionService()
24
24
  assert.strictEqual(service.sessionChanged, false)
25
- service.set({ userId: 'user-1' })
25
+ await service.set({ userId: 'user-1' })
26
26
  assert.strictEqual(service.sessionChanged, true)
27
27
  })
28
28
 
29
- test('should clear session', () => {
29
+ test('should clear session', async () => {
30
30
  const service = new PikkuSessionService()
31
31
  service.setInitial({ userId: 'user-1' })
32
- service.clear()
32
+ await service.clear()
33
33
  assert.strictEqual(service.get(), undefined)
34
34
  assert.strictEqual(service.sessionChanged, true)
35
35
  })
@@ -41,35 +41,69 @@ describe('PikkuSessionService', () => {
41
41
  assert.deepStrictEqual(frozen, { userId: 'user-1' })
42
42
  })
43
43
 
44
- test('should only freeze initial once', () => {
44
+ test('should only freeze initial once', async () => {
45
45
  const service = new PikkuSessionService()
46
46
  service.setInitial({ userId: 'user-1' })
47
47
  service.freezeInitial()
48
- service.set({ userId: 'user-2' })
48
+ await service.set({ userId: 'user-2' })
49
49
  const frozen2 = service.freezeInitial()
50
50
  assert.deepStrictEqual(frozen2, { userId: 'user-1' })
51
51
  })
52
52
 
53
- test('should throw when channelStore provided without channelId', () => {
54
- assert.throws(() => new PikkuSessionService({} as any), {
55
- message: 'Channel ID is required when using channel store',
56
- })
57
- })
58
-
59
- test('should use channelStore when provided', async () => {
53
+ test('should persist to sessionStore on set when pikkuUserId is set', async () => {
54
+ let storedId: string | undefined
60
55
  let storedSession: any
61
56
  const store = {
62
- setUserSession: (id: string, session: any) => {
57
+ get: async () => undefined,
58
+ set: async (id: string, session: any) => {
59
+ storedId = id
63
60
  storedSession = session
64
61
  },
65
- getChannelAndSession: (id: string) => ({ session: { userId: 'stored' } }),
62
+ clear: async () => {},
66
63
  }
67
- const service = new PikkuSessionService(store as any, 'ch-1')
68
- service.set({ userId: 'new' })
64
+ const service = new PikkuSessionService(store as any)
65
+ service.setPikkuUserId('user-123')
66
+ await service.set({ userId: 'new' })
67
+ assert.strictEqual(storedId, 'user-123')
69
68
  assert.deepStrictEqual(storedSession, { userId: 'new' })
69
+ })
70
70
 
71
- const session = service.get()
72
- assert.deepStrictEqual(session, { userId: 'stored' })
71
+ test('should not persist to sessionStore when pikkuUserId is not set', async () => {
72
+ let setCalled = false
73
+ const store = {
74
+ get: async () => undefined,
75
+ set: async () => {
76
+ setCalled = true
77
+ },
78
+ clear: async () => {},
79
+ }
80
+ const service = new PikkuSessionService(store as any)
81
+ await service.set({ userId: 'new' })
82
+ assert.strictEqual(setCalled, false)
83
+ })
84
+
85
+ test('should clear from sessionStore when pikkuUserId is set', async () => {
86
+ let clearedId: string | undefined
87
+ const store = {
88
+ get: async () => undefined,
89
+ set: async () => {},
90
+ clear: async (id: string) => {
91
+ clearedId = id
92
+ },
93
+ }
94
+ const service = new PikkuSessionService(store as any)
95
+ service.setPikkuUserId('user-123')
96
+ service.setInitial({ userId: 'user-123' })
97
+ await service.clear()
98
+ assert.strictEqual(clearedId, 'user-123')
99
+ assert.strictEqual(service.get(), undefined)
100
+ })
101
+
102
+ test('getPikkuUserId should return set value', () => {
103
+ const service = new PikkuSessionService()
104
+ assert.strictEqual(service.getPikkuUserId(), undefined)
105
+ service.setPikkuUserId('user-456')
106
+ assert.strictEqual(service.getPikkuUserId(), 'user-456')
73
107
  })
74
108
  })
75
109