Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/22848-service-slot-contracts-webhooks-trigger-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"@objectstack/spec": minor
---

feat(spec): `ServiceSlotContracts` types the `webhooks` and `trigger-api` slots

Clause-②: yes (widening)

`ServiceSlotContracts` (from `@objectstack/spec/contracts`) gains two entries:

- `webhooks: IWebhookService`. `@objectstack/plugin-webhooks` registers this slot with a service typed as `IWebhookService`.
- `'trigger-api': ITriggerApiService`. `@objectstack/trigger-api` registers this slot with a service typed as `ITriggerApiService`.

FROM → TO:

- `ServiceSlotContract<'webhooks'>` goes from `unknown` to `IWebhookService`, and `ServiceSlotContract<'trigger-api'>` goes from `unknown` to `ITriggerApiService`.
- A lookup keyed by `keyof ServiceSlotContracts` now accepts both names. One example is the runtime dispatcher's `resolveService` / `getRequestKernelService`, which used to answer `any` for them. It now answers the contract or `undefined`. So `resolveService(ctx, 'webhooks')` resolves to `IWebhookService | undefined`, and `?.handleRedeliver` has the declared member's type. The same holds for `'trigger-api'` and `?.handleInboundHook`.

Every member of both contracts is optional, so a reader still checks that the member it calls is present. No other slot's entry changes, and nothing changes at runtime: this is type information only.

Nobody needs to migrate. Existing casts at those lookups still compile. If you build an object typed as the whole `ServiceSlotContracts` map, it must now carry the two new keys. This repository and the pinned objectui checkout build no such object.
41 changes: 36 additions & 5 deletions packages/spec/src/contracts/core-service-contracts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
// widening to `any` — the difference between "no contract written yet" and
// "checked", which is the distinction this whole line of work exists to make.

import { describe, it, expect } from 'vitest';
import { describe, it, expect, expectTypeOf } from 'vitest';
import { CoreServiceName } from '../system/core-services.zod';
import type {
CoreServiceContracts, CoreServiceContract, ServiceSlotContracts, ServiceSlotContract,
Expand All @@ -19,6 +19,8 @@ import type { IHttpServer } from './http-server';
import type { IObjectQLEngine } from './objectql-engine';
import type { ISecurityService } from './security-service';
import type { IShareLinkService } from './share-link-service';
import type { IWebhookService } from './webhook-service';
import type { ITriggerApiService } from './trigger-api-service';

/** Compile-time assertion helper: fails to typecheck unless `T` is exactly `true`. */
type Expect<T extends true> = T;
Expand Down Expand Up @@ -110,18 +112,47 @@ describe('slot → contract ledger beyond the enum', () => {
expect(true).toBe(true);
});

it('types the webhooks and trigger-api slots through the member a reader probes', () => {
// [#22848] Both contracts declare every member optional, so a reader
// resolves the slot and probes for the one member it calls. This
// lookup has the runtime's keyed signature (`DomainHandlerDeps`'s
// `resolveService` and `getRequestKernelService` take
// `K extends keyof ServiceSlotContracts` and answer
// `ServiceSlotContract<K> | undefined`); spec cannot import the
// runtime, so it restates the one signature the map feeds.
const getService = <K extends keyof ServiceSlotContracts>(_name: K): ServiceSlotContract<K> | undefined =>
undefined;

expectTypeOf(getService('webhooks')).toEqualTypeOf<IWebhookService | undefined>();
expectTypeOf(getService('webhooks')?.handleRedeliver).toEqualTypeOf<IWebhookService['handleRedeliver']>();
expectTypeOf(getService('trigger-api')).toEqualTypeOf<ITriggerApiService | undefined>();
expectTypeOf(getService('trigger-api')?.handleInboundHook)
.toEqualTypeOf<ITriggerApiService['handleInboundHook']>();

// CONTROL: the same lookup resolves the slots that were already
// mapped exactly as before.
expectTypeOf(getService('http.server')).toEqualTypeOf<IHttpServer | undefined>();
expectTypeOf(getService('security')).toEqualTypeOf<ISecurityService | undefined>();
expectTypeOf(getService('data')).toEqualTypeOf<IDataEngine | undefined>();
expect(getService('webhooks')).toBeUndefined();
});

it('holds exactly the non-core slots whose contract is written', () => {
const extra: Array<Exclude<keyof ServiceSlotContracts, keyof CoreServiceContracts>> = [
'security', 'shareLinks', 'objectql', 'http.server', 'http-server',
];
const extra = [
'security', 'shareLinks', 'objectql', 'http.server', 'http-server', 'webhooks', 'trigger-api',
] as const;
// Exact, not merely a subset: a key added to or dropped from the
// non-core ledger without this list changing is a type error here.
expectTypeOf<Exclude<keyof ServiceSlotContracts, keyof CoreServiceContracts>>()
.toEqualTypeOf<(typeof extra)[number]>();
const slots = new Set<string>(CoreServiceName.options);
for (const key of extra) {
// The point of the split: these are deliberately NOT enum members.
// If one is ever promoted to a real core service, its entry moves
// into `CoreServiceContracts` and this assertion is what catches it.
expect(slots.has(key), `'${key}' is a CoreServiceName — move its entry into CoreServiceContracts`).toBe(false);
}
expect(extra).toHaveLength(5);
expect(extra).toHaveLength(7);
});

it('still resolves a slot with no written contract to unknown', () => {
Expand Down
24 changes: 24 additions & 0 deletions packages/spec/src/contracts/core-service-contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ import type { ISecurityService } from './security-service';
import type { IShareLinkService } from './share-link-service';
import type { IHttpServer } from './http-server';
import type { IObjectQLEngine } from './objectql-engine';
import type { IWebhookService } from './webhook-service';
import type { ITriggerApiService } from './trigger-api-service';

/**
* The evidenced slot → contract bindings.
Expand Down Expand Up @@ -173,6 +175,28 @@ export interface ServiceSlotContracts extends CoreServiceContracts {
* release boundary once none remain.
*/
'http-server': IHttpServer;
/**
* {@link IWebhookService}, registered by `@objectstack/plugin-webhooks`:
* `WebhookOutboxPlugin` registers its service under `webhooks`, typed as
* this contract, once its redelivery veto is installed, and leaves the slot
* empty otherwise.
*
* [#22848] Every member is optional, so a reader still probes for the one
* it calls (`handleRedeliver`); the entry types the probe instead of
* leaving the slot `unknown`.
*/
webhooks: IWebhookService;
/**
* {@link ITriggerApiService}, registered by `@objectstack/trigger-api`:
* `ApiTriggerPlugin` registers its service under `trigger-api`, typed as
* this contract, wherever it installs the `api` trigger, and leaves the
* slot empty otherwise.
*
* [#22848] Every member is optional, so a reader still probes for the one
* it calls (`handleInboundHook`); the entry types the probe instead of
* leaving the slot `unknown`.
*/
'trigger-api': ITriggerApiService;
}

/**
Expand Down
Loading