@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,44 @@
1
1
  ## 0.12.4
2
2
 
3
+ ## 0.12.18
4
+
5
+ ### Patch Changes
6
+
7
+ - 311c0c4: Unify session persistence through SessionStore, remove session blob from ChannelStore
8
+
9
+ - PikkuSessionService now persists sessions via SessionStore on set()/clear() instead of every function call
10
+ - ChannelStore no longer stores session data — maps channelId to pikkuUserId only
11
+ - ChannelStore API: setUserSession/getChannelAndSession replaced with setPikkuUserId/getChannel
12
+ - Serverless channel runner resolves sessions from SessionStore using pikkuUserId from ChannelStore
13
+
14
+ ## 0.12.17
15
+
16
+ ### Patch Changes
17
+
18
+ - 854737b: Add `ListInput<F, S>` / `ListOutput<Row>` / `Filter<F>` types for list-function primitives.
19
+
20
+ A "list function" is any Pikku function that returns a paginated collection. Adopting this shape unlocks a shared vocabulary across MCP tools, AI agents, typed RPC clients, and widget libraries — they all reason about cursor, filter, sort, and search uniformly.
21
+
22
+ These are purely structural constraints; no runtime behaviour change. A list function is still a normal `pikkuFunc` whose input extends `ListInput<F, S>` and output extends `ListOutput<Row>`.
23
+
24
+ ```ts
25
+ import { pikkuFunc } from '#pikku'
26
+ import type { ListInput, ListOutput } from '@pikku/core'
27
+
28
+ export const listSessions = pikkuFunc<
29
+ ListInput<{ status?: SessionStatus[] }, 'user' | 'status' | 'uploaded_at'>,
30
+ ListOutput<Session>
31
+ >({
32
+ func: async ({ kysely }, input) => {
33
+ /* ... */
34
+ },
35
+ })
36
+ ```
37
+
38
+ `Filter<F>` is a recursive AND/OR tree: arrays are AND of children, objects with label keys are OR of children, single-key objects with a field name from `F` are leaf predicates. Leaf operators mirror Prisma's vocabulary (`equals`, `in`, `notIn`, `gt`, `gte`, `lt`, `lte`, `contains`, `startsWith`, `endsWith`, `not`, `mode`).
39
+
40
+ Follow-ups (separate PRs): `applyFilter<DB>(qb, filter)` Kysely helper, `usePikkuListQuery` in the CLI's react-query generator, first-class MCP list-tool shape.
41
+
3
42
  ## 0.12.16
4
43
 
5
44
  ### Patch Changes
@@ -4,11 +4,31 @@ import { runPermissions } from '../permissions.js';
4
4
  import { pikkuState } from '../pikku-state.js';
5
5
  import { coerceTopLevelDataFromSchema, validateSchema } from '../schema.js';
6
6
  import { parseVersionedId } from '../version.js';
7
- import { createFunctionSessionWireProps } from '../services/user-session-service.js';
7
+ import { PikkuSessionService, createFunctionSessionWireProps, } from '../services/user-session-service.js';
8
8
  import { ForbiddenError, ReadonlySessionError } from '../errors/errors.js';
9
9
  import { PikkuCredentialWireService, createWireServicesCredentialWireProps, } from '../services/credential-wire-service.js';
10
+ import { defaultPikkuUserIdResolver } from '../services/pikku-user-id.js';
10
11
  import { rpcService } from '../wirings/rpc/rpc-runner.js';
11
12
  import { closeWireServices } from '../utils.js';
13
+ async function resolveSession(wire, singletonServices, sessionService) {
14
+ const pikkuUserId = defaultPikkuUserIdResolver(wire);
15
+ if (pikkuUserId) {
16
+ wire.pikkuUserId = pikkuUserId;
17
+ if (sessionService instanceof PikkuSessionService) {
18
+ sessionService.setPikkuUserId(pikkuUserId);
19
+ }
20
+ }
21
+ const { sessionStore } = singletonServices;
22
+ if (!sessionStore || !pikkuUserId)
23
+ return;
24
+ if (!wire.session) {
25
+ const stored = await sessionStore.get(pikkuUserId);
26
+ if (stored) {
27
+ wire.session = stored;
28
+ sessionService?.setInitial(stored);
29
+ }
30
+ }
31
+ }
12
32
  /**
13
33
  * Get or create singleton services for an addon package.
14
34
  * Services are cached in pikkuState to avoid recreation on each call.
@@ -134,6 +154,7 @@ export const runPikkuFunc = async (wireType, wireId, funcName, { singletonServic
134
154
  }
135
155
  // Helper function to run permissions and execute the function
136
156
  const executeFunction = async () => {
157
+ await resolveSession(resolvedWire, resolvedSingletonServices, sessionService);
137
158
  if (sessionService) {
138
159
  resolvedWire.session = sessionService.freezeInitial();
139
160
  resolvedWire.setSession = (s) => sessionService.set(s);
@@ -1,3 +1,4 @@
1
1
  export { addFunction, getAllFunctionNames } from './function-runner.js';
2
2
  export { pikkuAuth, pikkuPermission, pikkuPermissionFactory, pikkuApprovalDescription, } from './functions.types.js';
3
3
  export type { CorePikkuFunction, CorePikkuFunctionSessionless, CorePikkuFunctionConfig, CorePikkuAuth, CorePikkuAuthConfig, CorePikkuPermission, } from './functions.types.js';
4
+ export type { ListInput, ListOutput, Filter, LeafFilter, LeafValue, } from './list.types.js';
@@ -0,0 +1,113 @@
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
+ * Shape every list function accepts as input.
30
+ *
31
+ * @template F - User-defined filter shape. Each key is a filterable field.
32
+ * @template S - String union of sortable column names.
33
+ */
34
+ export interface ListInput<F extends Record<string, unknown> = Record<string, never>, S extends string = never> {
35
+ /** Opaque cursor from the previous page's `nextCursor`. */
36
+ cursor?: string;
37
+ /** Page size. Server may cap. */
38
+ limit?: number;
39
+ /** Ordered sort criteria — first entry is primary. */
40
+ sort?: Array<{
41
+ column: S;
42
+ direction: 'asc' | 'desc';
43
+ }>;
44
+ /** Structured filter tree. See {@link Filter}. */
45
+ filter?: Filter<F>;
46
+ /** Unstructured text search across server-configured fields. */
47
+ search?: string;
48
+ }
49
+ /**
50
+ * Shape every list function returns.
51
+ */
52
+ export interface ListOutput<Row> {
53
+ rows: Row[];
54
+ /** Null when no more pages. */
55
+ nextCursor: string | null;
56
+ /** Optional — backend may skip when expensive. */
57
+ totalCount?: number;
58
+ }
59
+ /**
60
+ * Leaf predicate value on a single field.
61
+ *
62
+ * Operator keywords (`equals`, `not`, `in`, `notIn`, `gt`, `gte`, `lt`,
63
+ * `lte`, `contains`, `startsWith`, `endsWith`, `mode`) mirror Prisma's
64
+ * vocabulary for dev familiarity. These keys are reserved and cannot be
65
+ * used as user field names in a Filter's `F` type.
66
+ */
67
+ export type LeafValue<T> = T | null | T[] | {
68
+ equals?: T | null;
69
+ not?: T | null | LeafValue<T>;
70
+ in?: T[];
71
+ notIn?: T[];
72
+ gt?: T;
73
+ gte?: T;
74
+ lt?: T;
75
+ lte?: T;
76
+ contains?: string;
77
+ startsWith?: string;
78
+ endsWith?: string;
79
+ mode?: 'sensitive' | 'insensitive';
80
+ };
81
+ /**
82
+ * A single-key object keyed by a field name from F, value is a leaf
83
+ * predicate. Single-key so the runtime discriminator is unambiguous:
84
+ * multi-key objects are OR groups.
85
+ */
86
+ export type LeafFilter<F extends Record<string, unknown>> = {
87
+ [K in keyof F]: {
88
+ [Key in K]: LeafValue<F[K]>;
89
+ };
90
+ }[keyof F];
91
+ /**
92
+ * Recursive filter tree.
93
+ *
94
+ * - `Array<Filter<F>>` — AND of children.
95
+ * - `{ [label: string]: Filter<F> }` — OR of children. Keys are arbitrary
96
+ * labels (unique strings), ignored at evaluation time.
97
+ * - `LeafFilter<F>` — single-key predicate on a declared field.
98
+ *
99
+ * @example "status = pending AND (therapist = kim OR therapist = park) AND uploaded_at > 2026-04-10"
100
+ * ```ts
101
+ * const filter: Filter<{ status: string; therapistId: string; uploadedAt: string }> = [
102
+ * { status: 'pending' },
103
+ * {
104
+ * kim: { therapistId: 'kim' },
105
+ * park: { therapistId: 'park' },
106
+ * },
107
+ * { uploadedAt: { gt: '2026-04-10' } },
108
+ * ]
109
+ * ```
110
+ */
111
+ export type Filter<F extends Record<string, unknown>> = LeafFilter<F> | Filter<F>[] | {
112
+ [label: string]: Filter<F>;
113
+ };
@@ -0,0 +1,28 @@
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
+ export {};
package/dist/index.d.ts CHANGED
@@ -6,6 +6,7 @@ export { pikkuAIMiddleware, pikkuChannelMiddleware, pikkuChannelMiddlewareFactor
6
6
  export type { CorePikkuAuth, CorePikkuAuthConfig, CorePikkuFunction, CorePikkuFunctionConfig, CorePikkuPermission, CorePikkuPermissionConfig, CorePikkuPermissionFactory, CorePikkuApprovalDescription, CorePermissionGroup, ZodLike, } from './function/functions.types.js';
7
7
  export { pikkuAuth, pikkuPermission, pikkuPermissionFactory, pikkuApprovalDescription, } from './function/functions.types.js';
8
8
  export { addFunction, getAllFunctionNames } from './function/index.js';
9
+ export type { ListInput, ListOutput, Filter, LeafFilter, LeafValue, } from './function/list.types.js';
9
10
  export { PikkuRequest } from './pikku-request.js';
10
11
  export { getRelativeTimeOffsetFromNow, parseDurationString, } from './time-utils.js';
11
12
  export type { RelativeTimeInput } from './time-utils.js';
@@ -0,0 +1,8 @@
1
+ import type { CoreUserSession } from '../types/core.types.js';
2
+ import type { SessionStore } from './session-store.js';
3
+ export declare class InMemorySessionStore<UserSession extends CoreUserSession = CoreUserSession> implements SessionStore<UserSession> {
4
+ private sessions;
5
+ get(pikkuUserId: string): Promise<UserSession | undefined>;
6
+ set(pikkuUserId: string, session: UserSession): Promise<void>;
7
+ clear(pikkuUserId: string): Promise<void>;
8
+ }
@@ -0,0 +1,12 @@
1
+ export class InMemorySessionStore {
2
+ sessions = new Map();
3
+ async get(pikkuUserId) {
4
+ return this.sessions.get(pikkuUserId);
5
+ }
6
+ async set(pikkuUserId, session) {
7
+ this.sessions.set(pikkuUserId, session);
8
+ }
9
+ async clear(pikkuUserId) {
10
+ this.sessions.delete(pikkuUserId);
11
+ }
12
+ }
@@ -36,4 +36,6 @@ export { TypedCredentialService } from './typed-credential-service.js';
36
36
  export type { CredentialStatusInfo, CredentialMetaInfo, } from './typed-credential-service.js';
37
37
  export type { VariableStatus, VariableMeta } from './typed-variables-service.js';
38
38
  export type { MetaService } from './meta-service.js';
39
+ export type { SessionStore } from './session-store.js';
40
+ export { InMemorySessionStore } from './in-memory-session-store.js';
39
41
  export type { MCPMeta, RPCMetaRecord, ServiceMeta, ServicesMetaRecord, MiddlewareDefinitionMeta, MiddlewareInstanceMeta, GroupMeta, MiddlewareGroupsMeta, PermissionDefinitionMeta, PermissionsGroupsMeta, FunctionsMeta, FunctionMeta, MiddlewareMeta, PermissionMeta, AgentsMeta, AgentMeta, } from './meta-service.js';
@@ -17,3 +17,4 @@ export { InMemoryTriggerService } from './in-memory-trigger-service.js';
17
17
  export { InMemoryAIRunStateService } from './in-memory-ai-run-state-service.js';
18
18
  export { LocalGatewayService } from './local-gateway-service.js';
19
19
  export { TypedCredentialService } from './typed-credential-service.js';
20
+ export { InMemorySessionStore } from './in-memory-session-store.js';
@@ -0,0 +1,6 @@
1
+ import type { CoreUserSession } from '../types/core.types.js';
2
+ export interface SessionStore<UserSession extends CoreUserSession = CoreUserSession> {
3
+ get(pikkuUserId: string): Promise<UserSession | undefined>;
4
+ set(pikkuUserId: string, session: UserSession): Promise<void>;
5
+ clear(pikkuUserId: string): Promise<void>;
6
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -1,5 +1,5 @@
1
- import type { ChannelStore } from '../wirings/channel/channel-store.js';
2
1
  import type { CoreUserSession } from '../types/core.types.js';
2
+ import type { SessionStore } from './session-store.js';
3
3
  export interface SessionService<UserSession extends CoreUserSession> {
4
4
  sessionChanged: boolean;
5
5
  initial: UserSession | undefined;
@@ -7,31 +7,33 @@ export interface SessionService<UserSession extends CoreUserSession> {
7
7
  freezeInitial(): UserSession | undefined;
8
8
  set(session: UserSession): Promise<void> | void;
9
9
  clear(): Promise<void> | void;
10
- get(): Promise<UserSession> | UserSession | undefined;
10
+ get(): UserSession | undefined;
11
11
  }
12
12
  export declare class PikkuSessionService<UserSession extends CoreUserSession> implements SessionService<UserSession> {
13
- private channelStore?;
14
- private channelId?;
13
+ private sessionStore?;
15
14
  sessionChanged: boolean;
16
15
  initial: UserSession | undefined;
17
16
  private session;
18
- constructor(channelStore?: ChannelStore<unknown, unknown, UserSession> | undefined, channelId?: string | undefined);
17
+ private pikkuUserId?;
18
+ constructor(sessionStore?: SessionStore<UserSession> | undefined);
19
+ setPikkuUserId(id: string): void;
20
+ getPikkuUserId(): string | undefined;
19
21
  setInitial(session: UserSession): void;
20
22
  freezeInitial(): UserSession | undefined;
21
- set(session: UserSession): void | Promise<void> | undefined;
22
- clear(): void | Promise<void> | undefined;
23
- get(): Promise<UserSession> | UserSession | undefined;
23
+ set(session: UserSession): Promise<void>;
24
+ clear(): Promise<void>;
25
+ get(): UserSession | undefined;
24
26
  }
25
27
  export declare function createMiddlewareSessionWireProps<UserSession extends CoreUserSession>(session: SessionService<UserSession>): {
26
28
  session: UserSession | undefined;
27
29
  setSession: (s: UserSession) => void;
28
- getSession: () => UserSession | Promise<UserSession> | undefined;
30
+ getSession: () => UserSession | undefined;
29
31
  hasSessionChanged: () => boolean;
30
32
  };
31
33
  export declare function createFunctionSessionWireProps<UserSession extends CoreUserSession>(session: SessionService<UserSession>): {
32
34
  session: UserSession | undefined;
33
35
  setSession: (s: UserSession) => void | Promise<void>;
34
36
  clearSession: () => void | Promise<void>;
35
- getSession: () => UserSession | Promise<UserSession> | undefined;
37
+ getSession: () => UserSession | undefined;
36
38
  hasSessionChanged: () => boolean;
37
39
  };
@@ -1,15 +1,17 @@
1
1
  export class PikkuSessionService {
2
- channelStore;
3
- channelId;
2
+ sessionStore;
4
3
  sessionChanged = false;
5
4
  initial;
6
5
  session;
7
- constructor(channelStore, channelId) {
8
- this.channelStore = channelStore;
9
- this.channelId = channelId;
10
- if (channelStore && !channelId) {
11
- throw new Error('Channel ID is required when using channel store');
12
- }
6
+ pikkuUserId;
7
+ constructor(sessionStore) {
8
+ this.sessionStore = sessionStore;
9
+ }
10
+ setPikkuUserId(id) {
11
+ this.pikkuUserId = id;
12
+ }
13
+ getPikkuUserId() {
14
+ return this.pikkuUserId;
13
15
  }
14
16
  setInitial(session) {
15
17
  this.session = session;
@@ -20,26 +22,21 @@ export class PikkuSessionService {
20
22
  }
21
23
  return this.initial;
22
24
  }
23
- set(session) {
25
+ async set(session) {
24
26
  this.sessionChanged = true;
25
27
  this.session = session;
26
- return this.channelStore?.setUserSession(this.channelId, session);
28
+ if (this.sessionStore && this.pikkuUserId) {
29
+ await this.sessionStore.set(this.pikkuUserId, session);
30
+ }
27
31
  }
28
- clear() {
32
+ async clear() {
29
33
  this.sessionChanged = true;
30
34
  this.session = undefined;
31
- return this.channelStore?.setUserSession(this.channelId, null);
35
+ if (this.sessionStore && this.pikkuUserId) {
36
+ await this.sessionStore.clear(this.pikkuUserId);
37
+ }
32
38
  }
33
39
  get() {
34
- if (this.channelStore) {
35
- const channel = this.channelStore.getChannelAndSession(this.channelId);
36
- if (channel instanceof Promise) {
37
- return channel.then(({ session }) => session);
38
- }
39
- else {
40
- return channel.session;
41
- }
42
- }
43
40
  return this.session;
44
41
  }
45
42
  }
@@ -8,6 +8,7 @@ import type { AIRunStateService } from '../services/ai-run-state-service.js';
8
8
  import type { SecretService } from '../services/secret-service.js';
9
9
  import type { CredentialService } from '../services/credential-service.js';
10
10
  import type { AgentRunService } from '../wirings/ai-agent/ai-agent.types.js';
11
+ import type { SessionStore } from '../services/session-store.js';
11
12
  export interface ServiceTestConfig {
12
13
  name: string;
13
14
  services: {
@@ -34,6 +35,7 @@ export interface ServiceTestConfig {
34
35
  }) => Promise<CredentialService & {
35
36
  rotateKEK?(): Promise<number>;
36
37
  }>;
38
+ sessionStore?: () => Promise<SessionStore>;
37
39
  };
38
40
  }
39
41
  export declare function defineServiceTests(config: ServiceTestConfig): void;
@@ -9,27 +9,31 @@ export function defineServiceTests(config) {
9
9
  before(async () => {
10
10
  store = await factory();
11
11
  });
12
- test('addChannel and getChannelAndSession', async () => {
12
+ test('addChannel and getChannel', async () => {
13
13
  await store.addChannel({
14
14
  channelId: 'ch-1',
15
15
  channelName: 'test-channel',
16
16
  openingData: { foo: 'bar' },
17
17
  });
18
- const result = await store.getChannelAndSession('ch-1');
18
+ const result = await store.getChannel('ch-1');
19
19
  assert.equal(result.channelId, 'ch-1');
20
20
  assert.equal(result.channelName, 'test-channel');
21
21
  assert.deepEqual(result.openingData, { foo: 'bar' });
22
- assert.deepEqual(result.session, {});
22
+ assert.equal(result.pikkuUserId, undefined);
23
23
  });
24
- test('setUserSession', async () => {
25
- const session = { userId: 'user-1' };
26
- await store.setUserSession('ch-1', session);
27
- const result = await store.getChannelAndSession('ch-1');
28
- assert.deepEqual(result.session, session);
24
+ test('setPikkuUserId', async () => {
25
+ await store.setPikkuUserId('ch-1', 'user-1');
26
+ const result = await store.getChannel('ch-1');
27
+ assert.equal(result.pikkuUserId, 'user-1');
29
28
  });
30
- test('getChannelAndSession throws for missing channel', async () => {
29
+ test('setPikkuUserId to null', async () => {
30
+ await store.setPikkuUserId('ch-1', null);
31
+ const result = await store.getChannel('ch-1');
32
+ assert.equal(result.pikkuUserId, undefined);
33
+ });
34
+ test('getChannel throws for missing channel', async () => {
31
35
  await assert.rejects(async () => {
32
- await store.getChannelAndSession('missing');
36
+ await store.getChannel('missing');
33
37
  }, { message: 'Channel not found: missing' });
34
38
  });
35
39
  test('removeChannels', async () => {
@@ -39,7 +43,7 @@ export function defineServiceTests(config) {
39
43
  });
40
44
  await store.removeChannels(['ch-2']);
41
45
  await assert.rejects(async () => {
42
- await store.getChannelAndSession('ch-2');
46
+ await store.getChannel('ch-2');
43
47
  });
44
48
  });
45
49
  test('removeChannels with empty array is no-op', async () => {
@@ -693,4 +697,39 @@ export function defineServiceTests(config) {
693
697
  });
694
698
  });
695
699
  }
700
+ if (services.sessionStore) {
701
+ const factory = services.sessionStore;
702
+ describe(`SessionStore [${name}]`, () => {
703
+ let store;
704
+ before(async () => {
705
+ store = await factory();
706
+ });
707
+ test('get returns undefined for unknown user', async () => {
708
+ const result = await store.get('unknown-user');
709
+ assert.equal(result, undefined);
710
+ });
711
+ test('set and get round-trip', async () => {
712
+ const session = { userId: 'user-1', organizationId: 'org-1' };
713
+ await store.set('user-1', session);
714
+ const result = await store.get('user-1');
715
+ assert.deepEqual(result, session);
716
+ });
717
+ test('set overwrites previous session', async () => {
718
+ await store.set('user-2', { userId: 'user-2', role: 'admin' });
719
+ await store.set('user-2', { userId: 'user-2', role: 'member' });
720
+ const result = await store.get('user-2');
721
+ assert.deepEqual(result, { userId: 'user-2', role: 'member' });
722
+ });
723
+ test('clear removes session', async () => {
724
+ await store.set('user-3', { userId: 'user-3' });
725
+ assert.ok(await store.get('user-3'));
726
+ await store.clear('user-3');
727
+ const result = await store.get('user-3');
728
+ assert.equal(result, undefined);
729
+ });
730
+ test('clear is no-op for unknown user', async () => {
731
+ await store.clear('nonexistent');
732
+ });
733
+ });
734
+ }
696
735
  }
@@ -25,6 +25,7 @@ import type { PikkuAIMiddlewareHooks } from '../wirings/ai-agent/ai-agent.types.
25
25
  import type { WorkflowRunService } from '../wirings/workflow/workflow.types.js';
26
26
  import type { CredentialService } from '../services/credential-service.js';
27
27
  import type { MetaService } from '../services/meta-service.js';
28
+ import type { SessionStore } from '../services/session-store.js';
28
29
  export type PikkuWiringTypes = 'http' | 'scheduler' | 'trigger' | 'channel' | 'rpc' | 'queue' | 'mcp' | 'cli' | 'workflow' | 'agent' | 'gateway';
29
30
  export interface FunctionServicesMeta {
30
31
  optimized: boolean;
@@ -181,6 +182,8 @@ export interface CoreSingletonServices<Config extends CoreConfig = CoreConfig> {
181
182
  credentialService?: CredentialService;
182
183
  /** Meta service for reading .pikku metadata files (filesystem on Node, R2/KV on CF) */
183
184
  metaService?: MetaService;
185
+ /** Session store for persisting user sessions keyed by pikkuUserId */
186
+ sessionStore?: SessionStore;
184
187
  }
185
188
  /**
186
189
  * Represents different forms of wire within Pikku and the outside world.
@@ -1,17 +1,16 @@
1
- import type { CoreUserSession } from '../../types/core.types.js';
2
1
  export type Channel<ChannelType = unknown, OpeningData = unknown> = {
3
2
  channelId: string;
4
3
  channelName: string;
5
4
  channelObject?: ChannelType;
6
5
  openingData?: OpeningData;
7
6
  };
8
- export declare abstract class ChannelStore<ChannelType = unknown, OpeningData = unknown, UserSession extends CoreUserSession = CoreUserSession, TypedChannel = Channel<ChannelType, OpeningData>> {
7
+ export declare abstract class ChannelStore<ChannelType = unknown, OpeningData = unknown, TypedChannel = Channel<ChannelType, OpeningData>> {
9
8
  abstract addChannel(channel: Channel<ChannelType, OpeningData>): Promise<void> | void;
10
9
  abstract removeChannels(channelId: string[]): Promise<void> | void;
11
- abstract setUserSession(channelId: string, userSession: UserSession | null): Promise<void> | void;
12
- abstract getChannelAndSession(channelId: string): Promise<TypedChannel & {
13
- session: UserSession;
10
+ abstract setPikkuUserId(channelId: string, pikkuUserId: string | null): Promise<void> | void;
11
+ abstract getChannel(channelId: string): Promise<TypedChannel & {
12
+ pikkuUserId?: string;
14
13
  }> | (TypedChannel & {
15
- session: UserSession;
14
+ pikkuUserId?: string;
16
15
  });
17
16
  }
@@ -15,7 +15,7 @@ export const runLocalChannel = async ({ channelId, request, response, route, ski
15
15
  let wireServices;
16
16
  let closedWireServices = false;
17
17
  let channelHandler;
18
- const userSession = new PikkuSessionService();
18
+ const userSession = new PikkuSessionService(singletonServices.sessionStore);
19
19
  let http;
20
20
  if (request) {
21
21
  http = createHTTPWire(request, response);
@@ -79,7 +79,8 @@ export const runLocalChannel = async ({ channelId, request, response, route, ski
79
79
  singletonServices.logger.error(`Error handling onConnect: ${e}`);
80
80
  const errorResponse = getErrorResponse(e);
81
81
  channel.send({
82
- error: errorResponse?.message ?? (isProduction() ? 'Internal server error' : e.message),
82
+ error: errorResponse?.message ??
83
+ (isProduction() ? 'Internal server error' : e.message),
83
84
  ...(!isProduction() && { errorName: e.constructor?.name }),
84
85
  });
85
86
  }
@@ -102,7 +103,8 @@ export const runLocalChannel = async ({ channelId, request, response, route, ski
102
103
  singletonServices.logger.error(`Error handling onDisconnect: ${e}`);
103
104
  const errorResponse = getErrorResponse(e);
104
105
  channel.send({
105
- error: errorResponse?.message ?? (isProduction() ? 'Internal server error' : e.message),
106
+ error: errorResponse?.message ??
107
+ (isProduction() ? 'Internal server error' : e.message),
106
108
  ...(!isProduction() && { errorName: e.constructor?.name }),
107
109
  });
108
110
  }
@@ -119,7 +121,8 @@ export const runLocalChannel = async ({ channelId, request, response, route, ski
119
121
  singletonServices.logger.error(e);
120
122
  const errorResponse = getErrorResponse(e);
121
123
  channel.send({
122
- error: errorResponse?.message ?? (isProduction() ? 'Internal server error' : e.message),
124
+ error: errorResponse?.message ??
125
+ (isProduction() ? 'Internal server error' : e.message),
123
126
  ...(!isProduction() && { errorName: e.constructor?.name }),
124
127
  });
125
128
  setTimeout(() => channel.close(), 200);