@pikku/core 0.12.128 → 0.12.130

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 CHANGED
@@ -1,3 +1,25 @@
1
+ ## 0.12.130
2
+
3
+ ### Patch Changes
4
+
5
+ - dfcd351: The deploy planner now binds a unit to the units of the functions its functions call with `rpc.invoke('name')` / `rpc.remote('name')`. Before, a caller and callee split into different units (e.g. a no-service function in `svc-base` calling a DB function in `svc-kysely`) got no service binding and failed on the deployed stage with "No service binding for function". The inspector records literal RPC names per function as `invokes` (single, double or substitution-free template quotes; `rpc!`, `wire.rpc`) and warns on a computed name, which the planner cannot see (#1883).
6
+ - cf40182: Deployed units that call `rpc.startWorkflow('x')` now get x's meta, so the call no longer fails with `WorkflowNotFoundError`. The inspector records `startsWorkflows` for literal `rpc.startWorkflow(...)` calls. It warns when a handler computes the workflow name or passes `rpc` to a helper, because the planner cannot see those calls.
7
+
8
+ With workflow queues, the deploy planner gives a starter unit the workflow meta and orchestrator queue meta only (new `--workflowMeta` filter), plus `workflow-state` and `queue` services. Without queues the start runs inline, so the whole workflow is bundled. Core's `startWorkflow` now requires the workflow registration only for inline runs; queued runs need only the meta.
9
+
10
+ - 5bce779: `pikku dev` registers webhook sources with their providers when `PIKKU_DEV_WEBHOOK_URL` is set. What it registered, signing secrets included, is kept in a git-ignored `.webhook-registrations.gen.json` next to `pikku.config.json`, so a database reset or `.pikku` wipe does not register again, and nothing reaches a provider while the url and events are unchanged. Registrations no source declares any more are warned about on every run, with the endpoint and label to delete by hand. The prefix defaults to `dev-<username>`; override it with `PIKKU_DEV_WEBHOOK_LABEL_PREFIX`. New in core: `reconcileWebhookRegistrations`.
11
+ - 5bce779: Webhook trigger sources are off until someone turns them on, and addons declare their own.
12
+
13
+ An addon calls `wireTriggerWebhookSource` in its own package, and an app that wires the addon gets the source (its route included) without declaring it: named and routed after the addon's namespace, so two instances get one each. A source the app declares under the same name wins.
14
+
15
+ Every source now has an `enabled` switch in the `triggerSourceStore`, off by default. `reconcileTriggerSources` registers only enabled sources and records each one's `baseUrl` and `labelPrefix`; a disabled source's route answers 404 without running `receive`. New in core: `enableTriggerSource` registers a source with its provider and `disableTriggerSource` stops it receiving, then tears it down, both at the recorded address unless one is given. The admin addon exposes them as `triggerSourceEnable` and `triggerSourceDisable`. The `pikkuTriggerSource` table gains `enabled`, `baseUrl` and `labelPrefix`: run `pikku db generate` for the migration.
16
+
17
+ ## 0.12.129
18
+
19
+ ### Patch Changes
20
+
21
+ - 1ab6eb4: Fix app functions reached through `rpc.invoke`/`rpc.exposed` (including the generated `/rpc/:rpcName` endpoint) running without their wire services. Since `singletonServicesOnly`, the forwarding `rpcCaller` built none and the callee inherited that, so any service from `pikkuWireServices` was `undefined`. The runner now resolves the callee's factory itself. Channels pass their per-connection set as the new `wireServices` option, which is reused and left open.
22
+
1
23
  ## 0.12.128
2
24
 
3
25
  ### Patch Changes
@@ -138,12 +138,26 @@ export type FunctionMeta = FunctionRuntimeMeta & Partial<{
138
138
  isDirectFunction: boolean;
139
139
  sourceFile: string;
140
140
  exportedName: string;
141
+ /**
142
+ * RPC names this function's own body calls by literal name through
143
+ * `rpc.invoke(...)` / `rpc.remote(...)`. The deploy planner binds the
144
+ * function's unit to each callee's unit from this; a call with a computed
145
+ * name is not listed, and is warned about instead.
146
+ */
147
+ invokes: string[];
141
148
  /** File containing the handler body when it differs from sourceFile (imported handlers) */
142
149
  bodySourceFile?: string;
143
150
  /** 1-indexed first line of the handler body (verbose meta; coverage mapping) */
144
151
  bodyStart: number;
145
152
  /** 1-indexed last line of the handler body (verbose meta; coverage mapping) */
146
153
  bodyEnd: number;
154
+ /**
155
+ * Workflow names this function's own body starts by literal name through
156
+ * `rpc.startWorkflow(...)`. The deploy planner bundles each one's meta
157
+ * into the function's unit, which is all a queued start needs. A call
158
+ * made from a helper the handler passes `rpc` to is not listed.
159
+ */
160
+ startsWorkflows: string[];
147
161
  } & CommonWireMeta>;
148
162
  export type FunctionsRuntimeMeta = Record<string, FunctionRuntimeMeta>;
149
163
  export type FunctionsMeta = Record<string, FunctionMeta>;
@@ -8,9 +8,14 @@ import type { AddonInstance } from '../wirings/addon/addon-runner.js';
8
8
  export declare const addFunction: (funcName: string, funcConfig: CorePikkuFunctionConfig<any, any, any, any, any>, packageName?: string | null) => void;
9
9
  export declare const getFunctionNames: (packageName?: string | null) => string[];
10
10
  export declare const getAllFunctionNames: () => string[];
11
- export declare const runPikkuFunc: <In = any, Out = any>(wireType: PikkuWiringTypes, wireId: string, funcName: string, { singletonServices, createWireServices, data, auth: wiringAuth, inheritedMiddleware, wireMiddleware, inheritedChannelMiddleware, wireChannelMiddleware, coerceDataFromSchema, wire, sessionService, credentialWireService, packageName, addonInstance, }: {
11
+ export declare const runPikkuFunc: <In = any, Out = any>(wireType: PikkuWiringTypes, wireId: string, funcName: string, { singletonServices, createWireServices, wireServices: providedWireServices, data, auth: wiringAuth, inheritedMiddleware, wireMiddleware, inheritedChannelMiddleware, wireChannelMiddleware, coerceDataFromSchema, wire, sessionService, credentialWireService, packageName, addonInstance, }: {
12
12
  singletonServices: CoreSingletonServices;
13
13
  createWireServices?: CreateWireServices;
14
+ /**
15
+ * Wire services the caller already built and still owns, such as a
16
+ * channel's per-connection set. Used as-is and never closed here.
17
+ */
18
+ wireServices?: Record<string, unknown>;
14
19
  data: () => Promise<In> | In;
15
20
  auth?: boolean;
16
21
  inheritedMiddleware?: MiddlewareMetadata[];
@@ -3,7 +3,7 @@ import { runMiddleware, combineMiddleware } from '../middleware-runner.js';
3
3
  import { combineChannelMiddleware, wrapChannelWithMiddleware, } from '../wirings/channel/channel-middleware-runner.js';
4
4
  import { runPermissions } from '../permissions.js';
5
5
  import { withoutSecrets } from '../services/secretless.js';
6
- import { pikkuState } from '../pikku-state.js';
6
+ import { getCreateWireServices, pikkuState } from '../pikku-state.js';
7
7
  import { applyDefaultsFromSchema, coerceTopLevelDataFromSchema, validateSchema, } from '../schema.js';
8
8
  import { parseVersionedId } from '../version.js';
9
9
  import { PikkuSessionService } from '../services/user-session-service.js';
@@ -64,7 +64,7 @@ export const getAllFunctionNames = () => {
64
64
  }
65
65
  return functions;
66
66
  };
67
- export const runPikkuFunc = async (wireType, wireId, funcName, { singletonServices, createWireServices, data, auth: wiringAuth, inheritedMiddleware, wireMiddleware, inheritedChannelMiddleware, wireChannelMiddleware, coerceDataFromSchema, wire, sessionService, credentialWireService, packageName = null, addonInstance, }) => {
67
+ export const runPikkuFunc = async (wireType, wireId, funcName, { singletonServices, createWireServices, wireServices: providedWireServices, data, auth: wiringAuth, inheritedMiddleware, wireMiddleware, inheritedChannelMiddleware, wireChannelMiddleware, coerceDataFromSchema, wire, sessionService, credentialWireService, packageName = null, addonInstance, }) => {
68
68
  wire.wireType ??= wireType;
69
69
  wire.wireId ??= wireId;
70
70
  const funcMap = pikkuState(packageName, 'function', 'functions');
@@ -118,13 +118,14 @@ export const runPikkuFunc = async (wireType, wireId, funcName, { singletonServic
118
118
  const resolvedSingletonServices = funcPackageName
119
119
  ? await getOrCreatePackageSingletonServices(funcPackageName, singletonServices, addonInstance)
120
120
  : singletonServices;
121
- let resolvedCreateWireServices = createWireServices;
122
- if (funcPackageName) {
123
- const factories = pikkuState(funcPackageName, 'package', 'factories');
124
- if (factories?.createWireServices) {
125
- resolvedCreateWireServices = factories.createWireServices;
126
- }
127
- }
121
+ // knowledge: decisions/internals/the-runner-resolves-wire-services-itself.md
122
+ const packageCreateWireServices = funcPackageName
123
+ ? pikkuState(funcPackageName, 'package', 'factories')?.createWireServices
124
+ : undefined;
125
+ const reusedWireServices = packageCreateWireServices
126
+ ? undefined
127
+ : providedWireServices;
128
+ const resolvedCreateWireServices = packageCreateWireServices ?? createWireServices ?? getCreateWireServices();
128
129
  const allChannelMiddleware = combineChannelMiddleware(wireType, wireId, {
129
130
  wireInheritedChannelMiddleware: inheritedChannelMiddleware,
130
131
  wireChannelMiddleware,
@@ -256,11 +257,14 @@ export const runPikkuFunc = async (wireType, wireId, funcName, { singletonServic
256
257
  let invocationAuditLog;
257
258
  let invocationAnalytics;
258
259
  try {
259
- wireServices = funcMeta.singletonServicesOnly
260
+ if (!funcMeta.singletonServicesOnly && !reusedWireServices) {
261
+ wireServices = (await resolvedCreateWireServices?.(resolvedSingletonServices, invocationWire));
262
+ }
263
+ const invocationWireServices = funcMeta.singletonServicesOnly
260
264
  ? undefined
261
- : (await resolvedCreateWireServices?.(resolvedSingletonServices, invocationWire));
262
- let services = wireServices && Object.keys(wireServices).length > 0
263
- ? { ...resolvedSingletonServices, ...wireServices }
265
+ : (reusedWireServices ?? wireServices);
266
+ let services = invocationWireServices && Object.keys(invocationWireServices).length > 0
267
+ ? { ...resolvedSingletonServices, ...invocationWireServices }
264
268
  : resolvedSingletonServices;
265
269
  // knowledge: decisions/internals/core-function-runner-restores-the-wire-fields-it-overwrites.md
266
270
  if (resolvedAuditConfig &&
@@ -286,7 +290,7 @@ export const runPikkuFunc = async (wireType, wireId, funcName, { singletonServic
286
290
  const callerPackageName = funcPackageName;
287
291
  Object.defineProperty(invocationWire, 'rpc', {
288
292
  get() {
289
- const rpc = rpcService.getContextRPCService(services, invocationWire, { sessionService }, 0, callerPackageName);
293
+ const rpc = rpcService.getContextRPCService(resolvedSingletonServices, invocationWire, { sessionService }, 0, callerPackageName);
290
294
  Object.defineProperty(invocationWire, 'rpc', {
291
295
  value: rpc,
292
296
  writable: true,
@@ -3,15 +3,22 @@ export type TriggerSourceKind = 'webhook';
3
3
  export type DeclaredTriggerSource = {
4
4
  name: string;
5
5
  kind: TriggerSourceKind;
6
+ /** Where the running app is served and how it labels provider endpoints: what enabling a source registers it with. */
7
+ baseUrl?: string;
8
+ labelPrefix?: string;
6
9
  };
7
10
  /**
8
11
  * What was registered with the provider for one declared trigger source.
9
12
  * `state` is what the last `setup` returned — provider identifiers only; any
10
13
  * secret it issued lives in the credential service.
11
14
  */
12
- export type TriggerSourceRow = DeclaredTriggerSource & {
15
+ export type TriggerSourceRow = Required<Pick<DeclaredTriggerSource, 'name' | 'kind'>> & {
16
+ baseUrl: string | null;
17
+ labelPrefix: string | null;
13
18
  /** False once the declaration has gone from code without a teardown: an orphan. */
14
19
  declared: boolean;
20
+ /** Only an enabled source is registered with its provider or receives events. Off until someone turns it on. */
21
+ enabled: boolean;
15
22
  status: string | null;
16
23
  state: WebhookSourceState | null;
17
24
  /** The last step's instructions or error. */
@@ -25,11 +32,12 @@ export type TriggerSourceResult = {
25
32
  };
26
33
  /** What each declared trigger source registered with its provider. */
27
34
  export interface TriggerSourceStore {
28
- /** Upserts the declared sources and marks the rest undeclared. */
35
+ /** Upserts the declared sources, keeping a known address when none is given, and marks the rest undeclared. */
29
36
  syncTriggerSources(sources: DeclaredTriggerSource[]): Promise<void>;
30
37
  listTriggerSources(): Promise<TriggerSourceRow[]>;
31
38
  getTriggerSource(name: string): Promise<TriggerSourceRow | null>;
32
39
  recordTriggerSource(name: string, result: TriggerSourceResult): Promise<void>;
40
+ setTriggerSourceEnabled(name: string, enabled: boolean): Promise<void>;
33
41
  deleteTriggerSource(name: string): Promise<void>;
34
42
  }
35
43
  export declare class InMemoryTriggerSourceStore implements TriggerSourceStore {
@@ -38,8 +46,12 @@ export declare class InMemoryTriggerSourceStore implements TriggerSourceStore {
38
46
  listTriggerSources(): Promise<{
39
47
  name: string;
40
48
  kind: TriggerSourceKind;
49
+ baseUrl: string | null;
50
+ labelPrefix: string | null;
41
51
  /** False once the declaration has gone from code without a teardown: an orphan. */
42
52
  declared: boolean;
53
+ /** Only an enabled source is registered with its provider or receives events. Off until someone turns it on. */
54
+ enabled: boolean;
43
55
  status: string | null;
44
56
  state: WebhookSourceState | null;
45
57
  /** The last step's instructions or error. */
@@ -49,8 +61,12 @@ export declare class InMemoryTriggerSourceStore implements TriggerSourceStore {
49
61
  getTriggerSource(name: string): Promise<{
50
62
  name: string;
51
63
  kind: TriggerSourceKind;
64
+ baseUrl: string | null;
65
+ labelPrefix: string | null;
52
66
  /** False once the declaration has gone from code without a teardown: an orphan. */
53
67
  declared: boolean;
68
+ /** Only an enabled source is registered with its provider or receives events. Off until someone turns it on. */
69
+ enabled: boolean;
54
70
  status: string | null;
55
71
  state: WebhookSourceState | null;
56
72
  /** The last step's instructions or error. */
@@ -58,5 +74,6 @@ export declare class InMemoryTriggerSourceStore implements TriggerSourceStore {
58
74
  updatedAt: string | null;
59
75
  } | null>;
60
76
  recordTriggerSource(name: string, result: TriggerSourceResult): Promise<void>;
77
+ setTriggerSourceEnabled(name: string, enabled: boolean): Promise<void>;
61
78
  deleteTriggerSource(name: string): Promise<void>;
62
79
  }
@@ -5,16 +5,23 @@ export class InMemoryTriggerSourceStore {
5
5
  for (const row of this.rows.values()) {
6
6
  row.declared = names.has(row.name);
7
7
  }
8
- for (const { name, kind } of sources) {
8
+ for (const { name, kind, baseUrl, labelPrefix } of sources) {
9
9
  const row = this.rows.get(name);
10
10
  if (row) {
11
11
  row.kind = kind;
12
+ if (baseUrl !== undefined)
13
+ row.baseUrl = baseUrl;
14
+ if (labelPrefix !== undefined)
15
+ row.labelPrefix = labelPrefix;
12
16
  continue;
13
17
  }
14
18
  this.rows.set(name, {
15
19
  name,
16
20
  kind,
21
+ baseUrl: baseUrl ?? null,
22
+ labelPrefix: labelPrefix ?? null,
17
23
  declared: true,
24
+ enabled: false,
18
25
  status: null,
19
26
  state: null,
20
27
  detail: null,
@@ -41,6 +48,12 @@ export class InMemoryTriggerSourceStore {
41
48
  row.detail = result.detail ?? null;
42
49
  row.updatedAt = new Date().toISOString();
43
50
  }
51
+ async setTriggerSourceEnabled(name, enabled) {
52
+ const row = this.rows.get(name);
53
+ if (!row)
54
+ throw new Error(`Unknown trigger source: ${name}`);
55
+ row.enabled = enabled;
56
+ }
44
57
  async deleteTriggerSource(name) {
45
58
  this.rows.delete(name);
46
59
  }
@@ -2,7 +2,7 @@ import type { CoreSingletonServices } from '../../types/core.types.js';
2
2
  import type { MiddlewareMetadata } from '../../middleware/middleware.types.js';
3
3
  import type { CoreChannel, ChannelMessageMeta } from './channel.types.js';
4
4
  import { type PikkuSessionService } from '../../services/user-session-service.js';
5
- export declare const runChannelLifecycleWithMiddleware: ({ channelConfig, meta, lifecycleConfig, lifecycleType, services, channel, data, channelMiddlewareMeta, userSession, }: {
5
+ export declare const runChannelLifecycleWithMiddleware: ({ channelConfig, meta, lifecycleConfig, lifecycleType, services, channel, data, channelMiddlewareMeta, userSession, wireServices, }: {
6
6
  channelConfig: CoreChannel<unknown, any>;
7
7
  meta: ChannelMessageMeta;
8
8
  lifecycleConfig: any;
@@ -16,4 +16,6 @@ export declare const runChannelLifecycleWithMiddleware: ({ channelConfig, meta,
16
16
  * just connected.
17
17
  */
18
18
  userSession?: PikkuSessionService<any>;
19
+ /** The connection's wire services, reused rather than rebuilt per call. */
20
+ wireServices?: Record<string, unknown>;
19
21
  }) => Promise<unknown>;
@@ -2,7 +2,7 @@ import { combineMiddleware, runMiddleware } from '../../middleware-runner.js';
2
2
  import { runPikkuFunc } from '../../function/function-runner.js';
3
3
  import { createMiddlewareSessionWireProps, } from '../../services/user-session-service.js';
4
4
  import { combineChannelMiddleware, wrapChannelWithMiddleware, } from './channel-middleware-runner.js';
5
- export const runChannelLifecycleWithMiddleware = async ({ channelConfig, meta, lifecycleConfig, lifecycleType, services, channel, data, channelMiddlewareMeta, userSession, }) => {
5
+ export const runChannelLifecycleWithMiddleware = async ({ channelConfig, meta, lifecycleConfig, lifecycleType, services, channel, data, channelMiddlewareMeta, userSession, wireServices, }) => {
6
6
  const lifecycleMiddleware = typeof lifecycleConfig === 'object' && 'middleware' in lifecycleConfig
7
7
  ? lifecycleConfig.middleware || []
8
8
  : [];
@@ -25,6 +25,7 @@ export const runChannelLifecycleWithMiddleware = async ({ channelConfig, meta, l
25
25
  const runLifecycle = async () => {
26
26
  return await runPikkuFunc('channel', channelConfig.name, meta.pikkuFuncId, {
27
27
  singletonServices: services,
28
+ wireServices,
28
29
  data: () => data,
29
30
  wire,
30
31
  sessionService: userSession,
@@ -1,7 +1,7 @@
1
1
  import type { CoreServices, CoreUserSession } from '../../types/core.types.js';
2
2
  import type { BinaryData, CoreChannel, PikkuChannelHandler } from './channel.types.js';
3
3
  import type { SessionService } from '../../services/user-session-service.js';
4
- export declare const processMessageHandlers: (services: CoreServices, channelConfig: CoreChannel<any, any>, channelHandler: PikkuChannelHandler, userSession?: SessionService<CoreUserSession>) => {
4
+ export declare const processMessageHandlers: (services: CoreServices, channelConfig: CoreChannel<any, any>, channelHandler: PikkuChannelHandler, userSession?: SessionService<CoreUserSession>, wireServices?: Record<string, unknown>) => {
5
5
  onMessage: (rawData: unknown) => Promise<unknown>;
6
6
  onBinaryMessage: ((data: BinaryData) => Promise<BinaryData | void>) | undefined;
7
7
  };
@@ -37,7 +37,7 @@ const validateAuth = (requiresSession, session, onMessage) => {
37
37
  }
38
38
  return true;
39
39
  };
40
- export const processMessageHandlers = (services, channelConfig, channelHandler, userSession) => {
40
+ export const processMessageHandlers = (services, channelConfig, channelHandler, userSession, wireServices) => {
41
41
  const logger = services.logger;
42
42
  const requiresSession = channelConfig.auth !== false;
43
43
  const processMessage = async (data, onMessage, routingProperty, routerValue) => {
@@ -76,6 +76,7 @@ export const processMessageHandlers = (services, channelConfig, channelHandler,
76
76
  const wireChannelMiddleware = channelConfig.channelMiddleware || [];
77
77
  return await runPikkuFunc('channel', cacheKey, pikkuFuncId, {
78
78
  singletonServices: services,
79
+ wireServices,
79
80
  data: () => data,
80
81
  inheritedMiddleware,
81
82
  wireMiddleware,
@@ -89,6 +89,7 @@ export const runLocalChannel = async ({ channelId, request, response, route, ski
89
89
  data: openingData,
90
90
  channelMiddlewareMeta: meta.channelMiddleware,
91
91
  userSession,
92
+ wireServices,
92
93
  });
93
94
  if (result !== undefined) {
94
95
  await channel.send(result);
@@ -117,6 +118,7 @@ export const runLocalChannel = async ({ channelId, request, response, route, ski
117
118
  channel,
118
119
  channelMiddlewareMeta: meta.channelMiddleware,
119
120
  userSession,
121
+ wireServices,
120
122
  });
121
123
  }
122
124
  catch (e) {
@@ -131,7 +133,7 @@ export const runLocalChannel = async ({ channelId, request, response, route, ski
131
133
  }
132
134
  await closeServices();
133
135
  });
134
- const { onMessage, onBinaryMessage } = processMessageHandlers(services, channelConfig, channelHandler, userSession);
136
+ const { onMessage, onBinaryMessage } = processMessageHandlers(services, channelConfig, channelHandler, userSession, wireServices);
135
137
  channelHandler.registerOnMessage(async (data) => {
136
138
  try {
137
139
  const result = await onMessage(data);
@@ -80,6 +80,7 @@ export const runChannelConnect = async ({ channelId, channelObject, request, res
80
80
  data: openingData,
81
81
  channelMiddlewareMeta: meta.channelMiddleware,
82
82
  userSession,
83
+ wireServices,
83
84
  });
84
85
  }
85
86
  const pikkuUserId = userSession.getPikkuUserId();
@@ -150,6 +151,7 @@ export const runChannelDisconnect = async ({ channelId, channelStore, channelHan
150
151
  channel,
151
152
  channelMiddlewareMeta: meta.channelMiddleware,
152
153
  userSession,
154
+ wireServices,
153
155
  });
154
156
  }
155
157
  catch (e) {
@@ -197,7 +199,7 @@ export const runChannelMessage = async ({ channelId, channelStore, channelHandle
197
199
  };
198
200
  let response;
199
201
  try {
200
- const { onMessage } = processMessageHandlers(services, channelConfig, channelHandler, userSession);
202
+ const { onMessage } = processMessageHandlers(services, channelConfig, channelHandler, userSession, wireServices);
201
203
  response = await onMessage(data);
202
204
  }
203
205
  catch (e) {
@@ -1,6 +1,6 @@
1
1
  export { wireTrigger, wireTriggerSource } from './trigger-runner.js';
2
- export { wireTriggerWebhookSource, receiveWebhookSourceRequest, dispatchWebhookSourceJob, runWebhookSourceLifecycle, declaredTriggerSources, reconcileTriggerSources, teardownTriggerSources, subscribedWebhookEvents, } from './webhook-source-runner.js';
3
- export type { WebhookSourceOutcome } from './webhook-source-runner.js';
2
+ export { wireTriggerWebhookSource, receiveWebhookSourceRequest, dispatchWebhookSourceJob, runWebhookSourceLifecycle, declaredTriggerSources, reconcileTriggerSources, enableTriggerSource, disableTriggerSource, reconcileWebhookRegistrations, teardownTriggerSources, subscribedWebhookEvents, } from './webhook-source-runner.js';
3
+ export type { OrphanedWebhookRegistration, WebhookRegistration, WebhookRegistrations, WebhookSourceOutcome, } from './webhook-source-runner.js';
4
4
  export { PIKKU_INCOMING_WEBHOOK_QUEUE_NAME } from './webhook-source.types.js';
5
5
  export type { CoreTriggerWebhookSource, TriggerEvent, WebhookCheckResult, WebhookLifecycleInput, WebhookReceiveResult, WebhookRequest, WebhookSetupResult, WebhookSourceJob, WebhookSourceMeta, WebhookSourceMethod, WebhookSourcesMeta, WebhookSourceState, WebhookTeardownInput, WebhookTeardownResult, } from './webhook-source.types.js';
6
6
  export { PikkuTriggerService } from './pikku-trigger-service.js';
@@ -1,4 +1,4 @@
1
1
  export { wireTrigger, wireTriggerSource } from './trigger-runner.js';
2
- export { wireTriggerWebhookSource, receiveWebhookSourceRequest, dispatchWebhookSourceJob, runWebhookSourceLifecycle, declaredTriggerSources, reconcileTriggerSources, teardownTriggerSources, subscribedWebhookEvents, } from './webhook-source-runner.js';
2
+ export { wireTriggerWebhookSource, receiveWebhookSourceRequest, dispatchWebhookSourceJob, runWebhookSourceLifecycle, declaredTriggerSources, reconcileTriggerSources, enableTriggerSource, disableTriggerSource, reconcileWebhookRegistrations, teardownTriggerSources, subscribedWebhookEvents, } from './webhook-source-runner.js';
3
3
  export { PIKKU_INCOMING_WEBHOOK_QUEUE_NAME } from './webhook-source.types.js';
4
4
  export { PikkuTriggerService } from './pikku-trigger-service.js';
@@ -9,7 +9,8 @@ export declare const subscribedWebhookEvents: (source: string) => string[];
9
9
  /**
10
10
  * The body of a webhook source's route: `receive`, validate, and queue every
11
11
  * event some trigger listens for. Events nobody listens for are answered and
12
- * dropped, so the provider does not retry them forever.
12
+ * dropped, so the provider does not retry them forever. With a
13
+ * `triggerSourceStore`, a source nobody enabled answers 404.
13
14
  */
14
15
  export declare const receiveWebhookSourceRequest: (sourceName: string, wire: {
15
16
  http?: PikkuHTTP;
@@ -44,15 +45,61 @@ export declare const runWebhookSourceLifecycle: ({ action, previous, singletonSe
44
45
  singletonServices?: CoreSingletonServices;
45
46
  }) => Promise<WebhookSourceOutcome[]>;
46
47
  /** The trigger sources this app declares, as the store is synced to. */
47
- export declare const declaredTriggerSources: () => DeclaredTriggerSource[];
48
+ export declare const declaredTriggerSources: (address?: LifecycleInput) => DeclaredTriggerSource[];
48
49
  /**
49
- * Registers every declared webhook source with its provider: `check`, then
50
+ * Registers every enabled webhook source with its provider: `check`, then
50
51
  * `setup` where it is missing or drifted, recording what was registered.
51
- * Run after a deployment goes live.
52
+ * A source nobody enabled is skipped. Run after a deployment goes live.
52
53
  */
53
54
  export declare const reconcileTriggerSources: ({ singletonServices, ...input }: LifecycleInput & {
54
55
  singletonServices?: CoreSingletonServices;
55
56
  }) => Promise<WebhookSourceOutcome[]>;
57
+ type SwitchInput = Partial<LifecycleInput> & {
58
+ name: string;
59
+ singletonServices?: CoreSingletonServices;
60
+ };
61
+ /**
62
+ * Turns a declared webhook source on and registers it with its provider, at
63
+ * the address given or the one the last deployment recorded.
64
+ */
65
+ export declare const enableTriggerSource: ({ singletonServices, ...input }: SwitchInput) => Promise<WebhookSourceOutcome>;
66
+ /**
67
+ * Turns a webhook source off: it stops receiving at once, then is removed
68
+ * from its provider. A failed teardown leaves it off and records the error.
69
+ */
70
+ export declare const disableTriggerSource: ({ singletonServices, ...input }: SwitchInput) => Promise<WebhookSourceOutcome>;
71
+ /** What one source registered with its provider, as a caller keeps it between runs. */
72
+ export type WebhookRegistration = {
73
+ url: string;
74
+ events: string[];
75
+ status: WebhookSourceOutcome['status'];
76
+ state?: WebhookSourceState;
77
+ /** The signing secrets `setup` stored, so a wiped credential store can be refilled without registering again. */
78
+ credentials?: Record<string, unknown>;
79
+ };
80
+ export type WebhookRegistrations = Record<string, WebhookRegistration>;
81
+ export type OrphanedWebhookRegistration = {
82
+ source: string;
83
+ url: string;
84
+ label: string;
85
+ };
86
+ /**
87
+ * Registers every declared webhook source against registrations the caller
88
+ * keeps rather than a `triggerSourceStore`, as `pikku dev` does in a file that
89
+ * outlives its database. A source whose url and events match what was
90
+ * registered is left alone without calling the provider, and its signing
91
+ * secrets are put back if the credential store lost them. Registrations no
92
+ * source declares any more are returned as orphans and kept, since only the
93
+ * code that set one up could tear it down.
94
+ */
95
+ export declare const reconcileWebhookRegistrations: ({ registrations, singletonServices, ...input }: LifecycleInput & {
96
+ registrations: WebhookRegistrations;
97
+ singletonServices?: CoreSingletonServices;
98
+ }) => Promise<{
99
+ registrations: WebhookRegistrations;
100
+ outcomes: WebhookSourceOutcome[];
101
+ orphans: OrphanedWebhookRegistration[];
102
+ }>;
56
103
  /**
57
104
  * Removes the named webhook sources from their providers and forgets them.
58
105
  * Run on the outgoing deployment for the sources the next release drops,
@@ -79,11 +79,16 @@ const validateEvents = async (source, events, logger) => {
79
79
  /**
80
80
  * The body of a webhook source's route: `receive`, validate, and queue every
81
81
  * event some trigger listens for. Events nobody listens for are answered and
82
- * dropped, so the provider does not retry them forever.
82
+ * dropped, so the provider does not retry them forever. With a
83
+ * `triggerSourceStore`, a source nobody enabled answers 404.
83
84
  */
84
85
  export const receiveWebhookSourceRequest = async (sourceName, wire) => {
85
86
  const singletonServices = getSingletonServices();
86
87
  const meta = getSourceMeta(sourceName);
88
+ const store = singletonServices.triggerSourceStore;
89
+ if (store && !(await store.getTriggerSource(sourceName))?.enabled) {
90
+ return new Response(null, { status: 404 });
91
+ }
87
92
  const source = pikkuState(null, 'trigger', 'webhookSources').get(sourceName);
88
93
  const request = await readRequest(wire.http);
89
94
  const result = meta.receive
@@ -152,9 +157,12 @@ export const dispatchWebhookSourceJob = async (job) => {
152
157
  const recordAttempt = (singletonServices, receiptId, trigger, error) => singletonServices.incomingWebhookService
153
158
  ?.recordAttempt(receiptId, { trigger, ...(error ? { error } : {}) })
154
159
  .catch((storeError) => singletonServices.logger.error(`Failed to record the attempt for webhook receipt ${receiptId}`, storeError));
155
- const runSourceLifecycleStep = async (meta, action, { baseUrl, labelPrefix }, previous, singletonServices) => {
156
- const url = `${baseUrl.replace(/\/+$/, '')}${meta.route}`;
157
- const label = `${labelPrefix}:${meta.name}`;
160
+ const sourceAddress = (meta, { baseUrl, labelPrefix }) => ({
161
+ url: `${baseUrl.replace(/\/+$/, '')}${meta.route}`,
162
+ label: `${labelPrefix}:${meta.name}`,
163
+ });
164
+ const runSourceLifecycleStep = async (meta, action, lifecycleInput, previous, singletonServices) => {
165
+ const { url, label } = sourceAddress(meta, lifecycleInput);
158
166
  const input = {
159
167
  url,
160
168
  label,
@@ -217,31 +225,165 @@ const requireTriggerSourceStore = (singletonServices) => {
217
225
  return store;
218
226
  };
219
227
  /** The trigger sources this app declares, as the store is synced to. */
220
- export const declaredTriggerSources = () => Object.keys(pikkuState(null, 'trigger', 'webhookSourceMeta')).map((name) => ({
228
+ export const declaredTriggerSources = (address) => Object.keys(pikkuState(null, 'trigger', 'webhookSourceMeta')).map((name) => ({
221
229
  name,
222
230
  kind: 'webhook',
231
+ ...address,
223
232
  }));
224
233
  const detailOf = (outcome) => outcome.instructions ?? outcome.error ?? outcome.reason ?? null;
234
+ const registerTriggerSource = async (store, meta, input, previous, singletonServices) => {
235
+ const outcome = await runSourceLifecycleStep(meta, 'setup', input, previous, singletonServices);
236
+ await store.recordTriggerSource(meta.name, {
237
+ status: outcome.status,
238
+ ...(outcome.state ? { state: outcome.state } : {}),
239
+ detail: detailOf(outcome),
240
+ });
241
+ return outcome;
242
+ };
225
243
  /**
226
- * Registers every declared webhook source with its provider: `check`, then
244
+ * Registers every enabled webhook source with its provider: `check`, then
227
245
  * `setup` where it is missing or drifted, recording what was registered.
228
- * Run after a deployment goes live.
246
+ * A source nobody enabled is skipped. Run after a deployment goes live.
229
247
  */
230
248
  export const reconcileTriggerSources = async ({ singletonServices = getSingletonServices(), ...input }) => {
231
249
  const store = requireTriggerSourceStore(singletonServices);
232
- await store.syncTriggerSources(declaredTriggerSources());
250
+ await store.syncTriggerSources(declaredTriggerSources({
251
+ baseUrl: input.baseUrl,
252
+ labelPrefix: input.labelPrefix,
253
+ }));
233
254
  const outcomes = [];
234
255
  for (const meta of Object.values(pikkuState(null, 'trigger', 'webhookSourceMeta'))) {
235
256
  const row = await store.getTriggerSource(meta.name);
236
- const outcome = await runSourceLifecycleStep(meta, 'setup', input, row?.state ?? undefined, singletonServices);
237
- await store.recordTriggerSource(meta.name, {
257
+ if (!row?.enabled) {
258
+ outcomes.push({
259
+ source: meta.name,
260
+ url: sourceAddress(meta, input).url,
261
+ status: 'skipped',
262
+ reason: 'disabled',
263
+ });
264
+ continue;
265
+ }
266
+ outcomes.push(await registerTriggerSource(store, meta, input, row.state ?? undefined, singletonServices));
267
+ }
268
+ return outcomes;
269
+ };
270
+ /** The address given, or the one the last deployment synced onto the row. */
271
+ const switchAddress = (row, { baseUrl, labelPrefix, name }) => {
272
+ const address = {
273
+ baseUrl: baseUrl ?? row?.baseUrl,
274
+ labelPrefix: labelPrefix ?? row?.labelPrefix,
275
+ };
276
+ if (!address.baseUrl || !address.labelPrefix) {
277
+ throw new Error(`Trigger source '${name}' has no address yet: pass baseUrl and labelPrefix, or deploy so reconcile records them.`);
278
+ }
279
+ return address;
280
+ };
281
+ /**
282
+ * Turns a declared webhook source on and registers it with its provider, at
283
+ * the address given or the one the last deployment recorded.
284
+ */
285
+ export const enableTriggerSource = async ({ singletonServices = getSingletonServices(), ...input }) => {
286
+ const store = requireTriggerSourceStore(singletonServices);
287
+ const meta = getSourceMeta(input.name);
288
+ await store.syncTriggerSources(declaredTriggerSources());
289
+ const row = await store.getTriggerSource(input.name);
290
+ const address = switchAddress(row, input);
291
+ await store.setTriggerSourceEnabled(input.name, true);
292
+ return registerTriggerSource(store, meta, address, row?.state ?? undefined, singletonServices);
293
+ };
294
+ /**
295
+ * Turns a webhook source off: it stops receiving at once, then is removed
296
+ * from its provider. A failed teardown leaves it off and records the error.
297
+ */
298
+ export const disableTriggerSource = async ({ singletonServices = getSingletonServices(), ...input }) => {
299
+ const store = requireTriggerSourceStore(singletonServices);
300
+ const meta = getSourceMeta(input.name);
301
+ const row = await store.getTriggerSource(input.name);
302
+ const address = switchAddress(row, input);
303
+ await store.setTriggerSourceEnabled(input.name, false);
304
+ const outcome = await runSourceLifecycleStep(meta, 'teardown', address, row?.state ?? undefined, singletonServices);
305
+ await store.recordTriggerSource(input.name, {
306
+ status: outcome.status,
307
+ ...(outcome.status === 'failed' ? {} : { state: null }),
308
+ detail: detailOf(outcome),
309
+ });
310
+ return outcome;
311
+ };
312
+ /** The singleton credentials the package owning a source's `setup` declares: where its signing secret goes. */
313
+ const sourceCredentialNames = (meta) => {
314
+ if (!meta.setup)
315
+ return [];
316
+ const namespace = meta.setup.includes(':') ? meta.setup.split(':')[0] : '';
317
+ const packageName = pikkuState(null, 'addons', 'packages').get(namespace)?.package ?? null;
318
+ const declared = pikkuState(packageName, 'package', 'credentialsMeta') ?? {};
319
+ return Object.entries(declared)
320
+ .filter(([, credential]) => credential.type === 'singleton' && !credential.oauth2)
321
+ .map(([name]) => name);
322
+ };
323
+ const sameEvents = (a, b) => a.length === b.length && a.every((event) => b.includes(event));
324
+ /**
325
+ * Registers every declared webhook source against registrations the caller
326
+ * keeps rather than a `triggerSourceStore`, as `pikku dev` does in a file that
327
+ * outlives its database. A source whose url and events match what was
328
+ * registered is left alone without calling the provider, and its signing
329
+ * secrets are put back if the credential store lost them. Registrations no
330
+ * source declares any more are returned as orphans and kept, since only the
331
+ * code that set one up could tear it down.
332
+ */
333
+ export const reconcileWebhookRegistrations = async ({ registrations, singletonServices = getSingletonServices(), ...input }) => {
334
+ const credentialService = singletonServices.credentialService;
335
+ const sources = pikkuState(null, 'trigger', 'webhookSourceMeta');
336
+ const next = {};
337
+ const outcomes = [];
338
+ for (const meta of Object.values(sources)) {
339
+ const { url } = sourceAddress(meta, input);
340
+ const events = subscribedWebhookEvents(meta.name);
341
+ const previous = registrations[meta.name];
342
+ const names = sourceCredentialNames(meta);
343
+ if (credentialService) {
344
+ for (const [name, value] of Object.entries(previous?.credentials ?? {})) {
345
+ if (!(await credentialService.has(name))) {
346
+ await credentialService.set(name, value);
347
+ }
348
+ }
349
+ }
350
+ if (previous &&
351
+ previous.status !== 'failed' &&
352
+ previous.url === url &&
353
+ sameEvents(previous.events, events)) {
354
+ next[meta.name] = previous;
355
+ outcomes.push({ source: meta.name, url, status: 'unchanged' });
356
+ continue;
357
+ }
358
+ const outcome = await runSourceLifecycleStep(meta, 'setup', input, previous?.state, singletonServices);
359
+ const credentials = {};
360
+ for (const name of names) {
361
+ const value = await credentialService?.get(name);
362
+ if (value != null)
363
+ credentials[name] = value;
364
+ }
365
+ const state = outcome.state ?? previous?.state;
366
+ next[meta.name] = {
367
+ url,
368
+ events,
238
369
  status: outcome.status,
239
- ...(outcome.state ? { state: outcome.state } : {}),
240
- detail: detailOf(outcome),
241
- });
370
+ ...(state ? { state } : {}),
371
+ ...(Object.keys(credentials).length ? { credentials } : {}),
372
+ };
242
373
  outcomes.push(outcome);
243
374
  }
244
- return outcomes;
375
+ const orphans = [];
376
+ for (const [source, registration] of Object.entries(registrations)) {
377
+ if (sources[source])
378
+ continue;
379
+ next[source] = registration;
380
+ orphans.push({
381
+ source,
382
+ url: registration.url,
383
+ label: `${input.labelPrefix}:${source}`,
384
+ });
385
+ }
386
+ return { registrations: next, outcomes, orphans };
245
387
  };
246
388
  /**
247
389
  * Removes the named webhook sources from their providers and forgets them.
@@ -385,15 +385,15 @@ export class PikkuWorkflowService {
385
385
  const shouldInline = options?.inline || !getSingletonServices()?.queueService;
386
386
  return runWorkflowGraph(this, name, input, rpcService, shouldInline, options?.startNode, wire, workflowMeta);
387
387
  }
388
- const registrations = pikkuState(packageName, 'workflows', 'registrations');
389
- const workflow = registrations.get(resolved?.resolvedName ?? name);
390
- if (!workflow) {
391
- throw new WorkflowNotFoundError(name);
392
- }
393
388
  if (!workflowMeta.graphHash) {
394
389
  throw new Error(`Missing workflow graphHash for '${name}'`);
395
390
  }
396
391
  const shouldInline = options?.inline || !getSingletonServices()?.queueService;
392
+ // A queued run needs only the meta; the orchestrator's unit runs it.
393
+ const registrations = pikkuState(packageName, 'workflows', 'registrations');
394
+ if (shouldInline && !registrations.get(resolved?.resolvedName ?? name)) {
395
+ throw new WorkflowNotFoundError(name);
396
+ }
397
397
  const runId = await this.createRun(name, input, shouldInline, workflowMeta.graphHash, wire, {
398
398
  deterministic: workflowMeta.deterministic,
399
399
  plannedSteps: workflowMeta.plannedSteps,
@@ -62,6 +62,7 @@ caller is entitled to assume.
62
62
  - [A column's at-rest form is an axis of its own](core-column-form-is-an-axis-of-its-own.md) — How a value is stored is independent of how sensitive it is, so form carries a required nominal brand on writes while classification stays optional on reads
63
63
  - [The data-classification brand is an optional property](core-data-classification-brand-is-an-optional-property.md) — Making **classification** required would break ordinary Kysely operands, so the brand only constrains values flowing out
64
64
  - [The function runner restores the wire fields it overwrites](core-function-runner-restores-the-wire-fields-it-overwrites.md) — One wire object is reused across nested calls, so functionId, audit, addonNamespace and rpc are saved and put back in a finally
65
+ - [The function runner resolves wire services itself](the-runner-resolves-wire-services-itself.md) — A function's wire services come from its own package's factory or the app's registered one, never borrowed from whoever called it
65
66
  - [Hot reload merges generated meta and never replaces it](core-hot-reload-merges-generated-meta-never-replaces-it.md) — Reloading codegen output must preserve runtime-registered meta, which no generated JSON contains
66
67
  - [Hot reload owns its module registry instead of re-importing](core-hot-reload-owns-its-module-registry.md) — Dev reload transpiles to CJS and runs modules through vm.compileFunction, because the native ESM loader map cannot be evicted
67
68
  - [Middleware order is resolution scope first, then priority](core-middleware-order-is-scope-then-priority.md) — Middleware is collected global to function, then stably sorted by priority, deduped, frozen and cached per wire
@@ -0,0 +1,31 @@
1
+ ---
2
+ type: decision
3
+ title: The function runner resolves wire services itself
4
+ description: A function's wire services come from its own package's factory or the app's registered one, never borrowed from whoever called it
5
+ tags: core
6
+ ---
7
+
8
+ # The function runner resolves wire services itself
9
+
10
+ `runPikkuFunc` picks the wire-services factory for the function it is about to
11
+ run: the function's addon package factory if it has one, else a factory the
12
+ transport passed explicitly, else the app's registered one
13
+ (`getCreateWireServices()`). A caller that passes nothing still gets the
14
+ function its wire services.
15
+
16
+ Before this, only the transports passed a factory. `rpc.invoke`, `rpc.exposed`,
17
+ gateway handlers and webhook sources passed none, so an app function reached
18
+ through them ran on whatever services its caller had already merged. That held
19
+ until `singletonServicesOnly` let a caller skip building wire services: the
20
+ generated `rpcCaller` behind `/rpc/:rpcName` reads no service, was marked, and
21
+ every app function behind it lost `events` and every other wire service. Nested
22
+ RPC therefore gets `resolvedSingletonServices`, not the caller's merged set.
23
+
24
+ Channels are the one caller that legitimately shares wire services: they build
25
+ one set per connection. They hand it over as `wireServices`, which the runner
26
+ uses as-is and does not close, because the connection owns its lifetime.
27
+
28
+ **What this rules out:** passing a caller's merged services down as
29
+ `singletonServices` to carry wire services into a nested call, and deciding in
30
+ one function's meta whether a different function gets wire services.
31
+ `singletonServicesOnly` only ever describes the function it is set on.
@@ -26,9 +26,10 @@ rotating it invalidates every credential at once.
26
26
 
27
27
  This is what lets a holder be handed less than everything:
28
28
 
29
- - The browser switcher gets `VITE_DEV_ACTOR_SECRETS`, one credential per
30
- declared persona. The root stays on the dev server, so a bundle can no longer
31
- hold the thing that is entitled to every persona.
29
+ - The browser switcher gets no credential at all. It used to be handed
30
+ `VITE_DEV_ACTOR_SECRETS`, one per persona; it now names a persona by id at
31
+ `/sign-in/persona`, which the server gates with the actor opt-in and the
32
+ `devSwitcher` flag, so a bundle holds nothing worth taking.
32
33
  - A run can be given `PIKKU_PERSONA_SECRETS` (`id=secret,…`, minted with
33
34
  `pikku persona secret`) instead of the root, and then it can sign in as those
34
35
  personas and no others. Asking for one outside the list throws naming the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.128",
3
+ "version": "0.12.130",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/pikkujs/pikku.git",
@@ -183,9 +183,12 @@
183
183
  "PIKKU_INCOMING_WEBHOOK_QUEUE_NAME",
184
184
  "PikkuTriggerService",
185
185
  "declaredTriggerSources",
186
+ "disableTriggerSource",
186
187
  "dispatchWebhookSourceJob",
188
+ "enableTriggerSource",
187
189
  "receiveWebhookSourceRequest",
188
190
  "reconcileTriggerSources",
191
+ "reconcileWebhookRegistrations",
189
192
  "runWebhookSourceLifecycle",
190
193
  "subscribedWebhookEvents",
191
194
  "teardownTriggerSources",