@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.
- package/CHANGELOG.md +39 -0
- package/dist/function/function-runner.js +22 -1
- package/dist/function/index.d.ts +1 -0
- package/dist/function/list.types.d.ts +113 -0
- package/dist/function/list.types.js +28 -0
- package/dist/index.d.ts +1 -0
- package/dist/services/in-memory-session-store.d.ts +8 -0
- package/dist/services/in-memory-session-store.js +12 -0
- package/dist/services/index.d.ts +2 -0
- package/dist/services/index.js +1 -0
- package/dist/services/session-store.d.ts +6 -0
- package/dist/services/session-store.js +1 -0
- package/dist/services/user-session-service.d.ts +12 -10
- package/dist/services/user-session-service.js +18 -21
- package/dist/testing/service-tests.d.ts +2 -0
- package/dist/testing/service-tests.js +50 -11
- package/dist/types/core.types.d.ts +3 -0
- package/dist/wirings/channel/channel-store.d.ts +5 -6
- package/dist/wirings/channel/local/local-channel-runner.js +7 -4
- package/dist/wirings/channel/serverless/serverless-channel-runner.js +25 -8
- package/dist/wirings/cli/cli-runner.js +1 -1
- package/dist/wirings/http/http-runner.js +1 -1
- package/dist/wirings/mcp/mcp-runner.js +1 -1
- package/dist/wirings/scheduler/scheduler-runner.js +1 -1
- package/dist/wirings/workflow/pikku-workflow-service.js +0 -1
- package/package.json +1 -1
- package/src/function/function-runner.ts +36 -1
- package/src/function/index.ts +7 -0
- package/src/function/list.types.test.ts +122 -0
- package/src/function/list.types.ts +121 -0
- package/src/index.ts +7 -0
- package/src/services/in-memory-session-store.test.ts +58 -0
- package/src/services/in-memory-session-store.ts +21 -0
- package/src/services/index.ts +2 -0
- package/src/services/session-store.ts +9 -0
- package/src/services/user-session-service.test.ts +53 -19
- package/src/services/user-session-service.ts +21 -22
- package/src/testing/service-tests.ts +64 -11
- package/src/types/core.types.ts +3 -0
- package/src/wirings/channel/channel-store.ts +5 -8
- package/src/wirings/channel/local/local-channel-runner.ts +10 -4
- package/src/wirings/channel/local/local-eventhub-service.test.ts +0 -5
- package/src/wirings/channel/serverless/serverless-channel-runner.ts +30 -9
- package/src/wirings/cli/cli-runner.ts +3 -1
- package/src/wirings/http/http-runner.ts +3 -1
- package/src/wirings/mcp/mcp-runner.ts +3 -1
- package/src/wirings/scheduler/scheduler-runner.ts +1 -1
- package/src/wirings/workflow/pikku-workflow-service.ts +0 -1
- 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(
|
|
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.
|
|
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,
|
|
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(
|
|
115
|
-
|
|
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,
|
|
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(
|
|
160
|
-
|
|
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
|
}
|
package/package.json
CHANGED
|
@@ -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 {
|
|
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)
|
package/src/function/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/services/index.ts
CHANGED
|
@@ -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
|
|
54
|
-
|
|
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
|
-
|
|
57
|
+
get: async () => undefined,
|
|
58
|
+
set: async (id: string, session: any) => {
|
|
59
|
+
storedId = id
|
|
63
60
|
storedSession = session
|
|
64
61
|
},
|
|
65
|
-
|
|
62
|
+
clear: async () => {},
|
|
66
63
|
}
|
|
67
|
-
const service = new PikkuSessionService(store as any
|
|
68
|
-
service.
|
|
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
|
-
|
|
72
|
-
|
|
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
|
|