@stardeck-customer-apps/testing 0.2.0 → 0.3.1
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/dist/index.d.mts +48 -1
- package/dist/index.d.ts +48 -1
- package/dist/index.js +130 -0
- package/dist/index.mjs +132 -0
- package/dist/next/headers-shim.js +1 -0
- package/dist/next/headers-shim.mjs +1 -0
- package/dist/setup.js +93 -0
- package/dist/setup.mjs +93 -0
- package/package.json +7 -2
package/dist/index.d.mts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { PGlite } from '@electric-sql/pglite';
|
|
2
|
+
import { ModuleSchemaOp, ModuleDataPort, IdentityClient } from '@stardeck-customer-apps/core';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* The user shape returned by project-auth's `getSession()`. Mirrors the
|
|
@@ -140,6 +141,52 @@ interface TestApp {
|
|
|
140
141
|
*/
|
|
141
142
|
declare function createTestApp(options?: TestAppOptions): Promise<TestApp>;
|
|
142
143
|
|
|
144
|
+
/** The minimal slice of a module definition the harness needs to stand it up. */
|
|
145
|
+
interface MountableModule {
|
|
146
|
+
schema: ModuleSchemaOp[];
|
|
147
|
+
}
|
|
148
|
+
interface ModuleSeedDeps {
|
|
149
|
+
/** Raw-SQL data port over the harness PGlite — what the module engine reads/writes through. */
|
|
150
|
+
data: ModuleDataPort;
|
|
151
|
+
/** Identity client backed by the simulated platform identity directory. */
|
|
152
|
+
identities: IdentityClient;
|
|
153
|
+
}
|
|
154
|
+
interface CreateModuleAppOptions {
|
|
155
|
+
/** Modules whose install schema is rendered to DDL and applied to the test DB. */
|
|
156
|
+
modules: MountableModule[];
|
|
157
|
+
/**
|
|
158
|
+
* Seed run once after the schema is applied, and again on every `reset()`.
|
|
159
|
+
* Receives the same data port + identity client the module engine uses, so a
|
|
160
|
+
* module's own `seed*` function drops straight in.
|
|
161
|
+
*/
|
|
162
|
+
seed?: (deps: ModuleSeedDeps) => Promise<void>;
|
|
163
|
+
/** Allow real outbound network (default false). */
|
|
164
|
+
allowNetwork?: boolean;
|
|
165
|
+
}
|
|
166
|
+
interface ModuleApp extends ModuleSeedDeps {
|
|
167
|
+
/** The underlying harness app (db, identities inspector, callRoute, asUser, …). */
|
|
168
|
+
app: TestApp;
|
|
169
|
+
/** Reset to a clean slate: drop + re-apply the module schema, clear the sim, re-run the seed. */
|
|
170
|
+
reset(): Promise<void>;
|
|
171
|
+
/** Tear down (restore global fetch, close the db). */
|
|
172
|
+
close(): Promise<void>;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Stand a capability module up on the in-process platform simulator — the **L2**
|
|
176
|
+
* harness. It renders the module's install schema to real DDL (the SAME renderer
|
|
177
|
+
* the control plane uses, drift-tested in apps/web), applies it to PGlite, wires a
|
|
178
|
+
* raw-SQL data port over it, and a real `integrations-sdk` identity client talking
|
|
179
|
+
* to the simulated identity directory (so scoping + governance behave as in prod).
|
|
180
|
+
*
|
|
181
|
+
* That's a near-end-to-end test of a module's runtime — schema + data port +
|
|
182
|
+
* identity integration + engine — minus the HTTP/RSC shell (those belong to a
|
|
183
|
+
* real-deploy smoke). The same seams back the local dev playground.
|
|
184
|
+
*
|
|
185
|
+
* Adding a module costs one co-located test file calling this with its own
|
|
186
|
+
* definition + seed; there is no per-module harness to maintain.
|
|
187
|
+
*/
|
|
188
|
+
declare function createModuleApp(options: CreateModuleAppOptions): Promise<ModuleApp>;
|
|
189
|
+
|
|
143
190
|
type RouteHandler<Req extends Request, P> = (request: Req, context: {
|
|
144
191
|
params: Promise<P>;
|
|
145
192
|
}) => Promise<Response> | Response;
|
|
@@ -183,4 +230,4 @@ declare const TEST_ENV_DEFAULTS: {
|
|
|
183
230
|
/** Default location of the DDL snapshot written by `generate-types`. */
|
|
184
231
|
declare const DEFAULT_SCHEMA_PATH = "./src/generated/data-store-schema.sql";
|
|
185
232
|
|
|
186
|
-
export { CONTROL_PLANE_TEST_URL, type CallRouteOptions, type CapturedEmail, type CapturedIdentity, type CapturedIdentityLink, DATA_STORE_TEST_HOST, DEFAULT_SCHEMA_PATH, type SessionTokens, TEST_ENV_DEFAULTS, type TestApp, type TestAppOptions, type TestDirectory, type TestInbox, type TestUser, WORKFLOW_NAME_PREFIX, callRoute, createTestApp, describeWorkflow, parseWorkflowName };
|
|
233
|
+
export { CONTROL_PLANE_TEST_URL, type CallRouteOptions, type CapturedEmail, type CapturedIdentity, type CapturedIdentityLink, type CreateModuleAppOptions, DATA_STORE_TEST_HOST, DEFAULT_SCHEMA_PATH, type ModuleApp, type ModuleSeedDeps, type MountableModule, type SessionTokens, TEST_ENV_DEFAULTS, type TestApp, type TestAppOptions, type TestDirectory, type TestInbox, type TestUser, WORKFLOW_NAME_PREFIX, callRoute, createModuleApp, createTestApp, describeWorkflow, parseWorkflowName };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { PGlite } from '@electric-sql/pglite';
|
|
2
|
+
import { ModuleSchemaOp, ModuleDataPort, IdentityClient } from '@stardeck-customer-apps/core';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* The user shape returned by project-auth's `getSession()`. Mirrors the
|
|
@@ -140,6 +141,52 @@ interface TestApp {
|
|
|
140
141
|
*/
|
|
141
142
|
declare function createTestApp(options?: TestAppOptions): Promise<TestApp>;
|
|
142
143
|
|
|
144
|
+
/** The minimal slice of a module definition the harness needs to stand it up. */
|
|
145
|
+
interface MountableModule {
|
|
146
|
+
schema: ModuleSchemaOp[];
|
|
147
|
+
}
|
|
148
|
+
interface ModuleSeedDeps {
|
|
149
|
+
/** Raw-SQL data port over the harness PGlite — what the module engine reads/writes through. */
|
|
150
|
+
data: ModuleDataPort;
|
|
151
|
+
/** Identity client backed by the simulated platform identity directory. */
|
|
152
|
+
identities: IdentityClient;
|
|
153
|
+
}
|
|
154
|
+
interface CreateModuleAppOptions {
|
|
155
|
+
/** Modules whose install schema is rendered to DDL and applied to the test DB. */
|
|
156
|
+
modules: MountableModule[];
|
|
157
|
+
/**
|
|
158
|
+
* Seed run once after the schema is applied, and again on every `reset()`.
|
|
159
|
+
* Receives the same data port + identity client the module engine uses, so a
|
|
160
|
+
* module's own `seed*` function drops straight in.
|
|
161
|
+
*/
|
|
162
|
+
seed?: (deps: ModuleSeedDeps) => Promise<void>;
|
|
163
|
+
/** Allow real outbound network (default false). */
|
|
164
|
+
allowNetwork?: boolean;
|
|
165
|
+
}
|
|
166
|
+
interface ModuleApp extends ModuleSeedDeps {
|
|
167
|
+
/** The underlying harness app (db, identities inspector, callRoute, asUser, …). */
|
|
168
|
+
app: TestApp;
|
|
169
|
+
/** Reset to a clean slate: drop + re-apply the module schema, clear the sim, re-run the seed. */
|
|
170
|
+
reset(): Promise<void>;
|
|
171
|
+
/** Tear down (restore global fetch, close the db). */
|
|
172
|
+
close(): Promise<void>;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Stand a capability module up on the in-process platform simulator — the **L2**
|
|
176
|
+
* harness. It renders the module's install schema to real DDL (the SAME renderer
|
|
177
|
+
* the control plane uses, drift-tested in apps/web), applies it to PGlite, wires a
|
|
178
|
+
* raw-SQL data port over it, and a real `integrations-sdk` identity client talking
|
|
179
|
+
* to the simulated identity directory (so scoping + governance behave as in prod).
|
|
180
|
+
*
|
|
181
|
+
* That's a near-end-to-end test of a module's runtime — schema + data port +
|
|
182
|
+
* identity integration + engine — minus the HTTP/RSC shell (those belong to a
|
|
183
|
+
* real-deploy smoke). The same seams back the local dev playground.
|
|
184
|
+
*
|
|
185
|
+
* Adding a module costs one co-located test file calling this with its own
|
|
186
|
+
* definition + seed; there is no per-module harness to maintain.
|
|
187
|
+
*/
|
|
188
|
+
declare function createModuleApp(options: CreateModuleAppOptions): Promise<ModuleApp>;
|
|
189
|
+
|
|
143
190
|
type RouteHandler<Req extends Request, P> = (request: Req, context: {
|
|
144
191
|
params: Promise<P>;
|
|
145
192
|
}) => Promise<Response> | Response;
|
|
@@ -183,4 +230,4 @@ declare const TEST_ENV_DEFAULTS: {
|
|
|
183
230
|
/** Default location of the DDL snapshot written by `generate-types`. */
|
|
184
231
|
declare const DEFAULT_SCHEMA_PATH = "./src/generated/data-store-schema.sql";
|
|
185
232
|
|
|
186
|
-
export { CONTROL_PLANE_TEST_URL, type CallRouteOptions, type CapturedEmail, type CapturedIdentity, type CapturedIdentityLink, DATA_STORE_TEST_HOST, DEFAULT_SCHEMA_PATH, type SessionTokens, TEST_ENV_DEFAULTS, type TestApp, type TestAppOptions, type TestDirectory, type TestInbox, type TestUser, WORKFLOW_NAME_PREFIX, callRoute, createTestApp, describeWorkflow, parseWorkflowName };
|
|
233
|
+
export { CONTROL_PLANE_TEST_URL, type CallRouteOptions, type CapturedEmail, type CapturedIdentity, type CapturedIdentityLink, type CreateModuleAppOptions, DATA_STORE_TEST_HOST, DEFAULT_SCHEMA_PATH, type ModuleApp, type ModuleSeedDeps, type MountableModule, type SessionTokens, TEST_ENV_DEFAULTS, type TestApp, type TestAppOptions, type TestDirectory, type TestInbox, type TestUser, WORKFLOW_NAME_PREFIX, callRoute, createModuleApp, createTestApp, describeWorkflow, parseWorkflowName };
|
package/dist/index.js
CHANGED
|
@@ -36,6 +36,7 @@ __export(index_exports, {
|
|
|
36
36
|
TEST_ENV_DEFAULTS: () => TEST_ENV_DEFAULTS,
|
|
37
37
|
WORKFLOW_NAME_PREFIX: () => WORKFLOW_NAME_PREFIX,
|
|
38
38
|
callRoute: () => callRoute,
|
|
39
|
+
createModuleApp: () => createModuleApp,
|
|
39
40
|
createTestApp: () => createTestApp,
|
|
40
41
|
describeWorkflow: () => describeWorkflow,
|
|
41
42
|
parseWorkflowName: () => parseWorkflowName
|
|
@@ -98,6 +99,7 @@ var state = globalSingleton("state", () => ({
|
|
|
98
99
|
emailCounter: 0,
|
|
99
100
|
identities: /* @__PURE__ */ new Map(),
|
|
100
101
|
identityLinks: [],
|
|
102
|
+
identityMemory: [],
|
|
101
103
|
allowNetwork: false
|
|
102
104
|
}));
|
|
103
105
|
function requireDb() {
|
|
@@ -755,16 +757,108 @@ async function handleAttachLink(identityId, request) {
|
|
|
755
757
|
state.identityLinks.push(link);
|
|
756
758
|
return success({ link });
|
|
757
759
|
}
|
|
760
|
+
function memoryDto(m) {
|
|
761
|
+
return {
|
|
762
|
+
id: m.id,
|
|
763
|
+
source: m.source,
|
|
764
|
+
kind: m.kind,
|
|
765
|
+
content: m.content,
|
|
766
|
+
metadata: m.metadata,
|
|
767
|
+
createdAt: m.createdAt
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
async function handleResolve(request) {
|
|
771
|
+
const body = await readBody(request);
|
|
772
|
+
const type = body.type;
|
|
773
|
+
if (type !== "person" && type !== "account") {
|
|
774
|
+
return failure("type must be 'person' or 'account'");
|
|
775
|
+
}
|
|
776
|
+
if (type === "account") {
|
|
777
|
+
return failure(
|
|
778
|
+
"resolveOrCreate is for channel-linked persons; an account is not resolvable by link \u2014 create it explicitly instead.",
|
|
779
|
+
409
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
const link = body.link;
|
|
783
|
+
const kind = link?.kind;
|
|
784
|
+
const externalId = link?.externalId;
|
|
785
|
+
if (typeof kind !== "string" || !LINK_KINDS.has(kind)) {
|
|
786
|
+
return failure(`link.kind must be one of: ${[...LINK_KINDS].join(", ")}`);
|
|
787
|
+
}
|
|
788
|
+
if (typeof externalId !== "string" || !externalId) {
|
|
789
|
+
return failure("link.externalId is required");
|
|
790
|
+
}
|
|
791
|
+
const existingLink = state.identityLinks.find(
|
|
792
|
+
(l) => l.kind === kind && l.externalId === externalId
|
|
793
|
+
);
|
|
794
|
+
if (existingLink) {
|
|
795
|
+
const identity2 = state.identities.get(existingLink.identityId);
|
|
796
|
+
if (identity2) return success({ identity: identity2, created: false });
|
|
797
|
+
}
|
|
798
|
+
const identity = {
|
|
799
|
+
id: import_node_crypto3.default.randomUUID(),
|
|
800
|
+
type,
|
|
801
|
+
parentId: null,
|
|
802
|
+
displayName: body.displayName ?? null,
|
|
803
|
+
profile: body.profile ?? {},
|
|
804
|
+
status: "active",
|
|
805
|
+
mergedIntoId: null,
|
|
806
|
+
externalRef: null,
|
|
807
|
+
createdAt: now(),
|
|
808
|
+
updatedAt: now()
|
|
809
|
+
};
|
|
810
|
+
state.identities.set(identity.id, identity);
|
|
811
|
+
state.identityLinks.push({
|
|
812
|
+
id: import_node_crypto3.default.randomUUID(),
|
|
813
|
+
identityId: identity.id,
|
|
814
|
+
kind,
|
|
815
|
+
externalId,
|
|
816
|
+
verified: true,
|
|
817
|
+
createdAt: now()
|
|
818
|
+
});
|
|
819
|
+
return success({ identity, created: true });
|
|
820
|
+
}
|
|
821
|
+
async function handleWriteMemory(identityId, request) {
|
|
822
|
+
if (!state.identities.get(identityId)) return failure("Identity not found", 404);
|
|
823
|
+
const body = await readBody(request);
|
|
824
|
+
if (typeof body.content !== "string" || !body.content) return failure("content is required");
|
|
825
|
+
const entry = {
|
|
826
|
+
id: import_node_crypto3.default.randomUUID(),
|
|
827
|
+
identityId,
|
|
828
|
+
source: typeof body.source === "string" ? body.source : "app",
|
|
829
|
+
kind: typeof body.kind === "string" ? body.kind : "fact",
|
|
830
|
+
content: body.content,
|
|
831
|
+
metadata: body.metadata ?? {},
|
|
832
|
+
createdAt: now()
|
|
833
|
+
};
|
|
834
|
+
state.identityMemory.push(entry);
|
|
835
|
+
return success({ memory: memoryDto(entry) });
|
|
836
|
+
}
|
|
837
|
+
function handleListMemory(identityId, request) {
|
|
838
|
+
if (!state.identities.get(identityId)) return failure("Identity not found", 404);
|
|
839
|
+
let rows = state.identityMemory.filter((m) => m.identityId === identityId).reverse();
|
|
840
|
+
const limitParam = new URL(request.url).searchParams.get("limit");
|
|
841
|
+
if (limitParam) rows = rows.slice(0, Math.max(1, parseInt(limitParam, 10) || 0));
|
|
842
|
+
return success({ memories: rows.map(memoryDto) });
|
|
843
|
+
}
|
|
758
844
|
async function handleIdentitiesRequest(request, subPath) {
|
|
759
845
|
const method = request.method;
|
|
760
846
|
if (subPath === "" || subPath === "/") {
|
|
761
847
|
if (method === "GET") return handleList(request);
|
|
762
848
|
if (method === "POST") return handleCreate(request);
|
|
763
849
|
}
|
|
850
|
+
if (subPath === "/resolve" && method === "POST") {
|
|
851
|
+
return handleResolve(request);
|
|
852
|
+
}
|
|
764
853
|
const linksMatch = subPath.match(/^\/([^/]+)\/links$/);
|
|
765
854
|
if (linksMatch && method === "POST") {
|
|
766
855
|
return handleAttachLink(linksMatch[1], request);
|
|
767
856
|
}
|
|
857
|
+
const memoryMatch = subPath.match(/^\/([^/]+)\/memory$/);
|
|
858
|
+
if (memoryMatch) {
|
|
859
|
+
if (method === "GET") return handleListMemory(memoryMatch[1], request);
|
|
860
|
+
if (method === "POST") return handleWriteMemory(memoryMatch[1], request);
|
|
861
|
+
}
|
|
768
862
|
const singleMatch = subPath.match(/^\/([^/]+)$/);
|
|
769
863
|
if (singleMatch) {
|
|
770
864
|
if (method === "GET") return handleGet(singleMatch[1]);
|
|
@@ -964,6 +1058,7 @@ async function createTestApp(options = {}) {
|
|
|
964
1058
|
state.emailCounter = 0;
|
|
965
1059
|
state.identities.clear();
|
|
966
1060
|
state.identityLinks = [];
|
|
1061
|
+
state.identityMemory = [];
|
|
967
1062
|
},
|
|
968
1063
|
async close() {
|
|
969
1064
|
state.db = null;
|
|
@@ -973,6 +1068,7 @@ async function createTestApp(options = {}) {
|
|
|
973
1068
|
state.emails = [];
|
|
974
1069
|
state.identities.clear();
|
|
975
1070
|
state.identityLinks = [];
|
|
1071
|
+
state.identityMemory = [];
|
|
976
1072
|
uninstallFetchRouter();
|
|
977
1073
|
await db.close();
|
|
978
1074
|
}
|
|
@@ -980,6 +1076,39 @@ async function createTestApp(options = {}) {
|
|
|
980
1076
|
return app;
|
|
981
1077
|
}
|
|
982
1078
|
|
|
1079
|
+
// src/module-app.ts
|
|
1080
|
+
var import_core = require("@stardeck-customer-apps/core");
|
|
1081
|
+
async function createModuleApp(options) {
|
|
1082
|
+
const schemaSql = options.modules.map((m) => (0, import_core.renderSchemaOpsToSql)(m.schema)).join("\n\n");
|
|
1083
|
+
const app = await createTestApp({ schemaSql, allowNetwork: options.allowNetwork });
|
|
1084
|
+
const sql = { query: (text, params) => app.db.query(text, params ?? []) };
|
|
1085
|
+
const data = (0, import_core.makeSqlPort)(sql);
|
|
1086
|
+
const { createIntegrationsClient } = await import("@stardeck-customer-apps/integrations-sdk");
|
|
1087
|
+
const identities = createIntegrationsClient({
|
|
1088
|
+
controlPlaneUrl: TEST_ENV_DEFAULTS.CONTROL_PLANE_URL,
|
|
1089
|
+
organizationId: TEST_ENV_DEFAULTS.ORGANIZATION_ID,
|
|
1090
|
+
projectId: TEST_ENV_DEFAULTS.PROJECT_ID,
|
|
1091
|
+
deploymentId: TEST_ENV_DEFAULTS.DEPLOYMENT_ID,
|
|
1092
|
+
deploymentSecret: TEST_ENV_DEFAULTS.DEPLOYMENT_SECRET
|
|
1093
|
+
}).identities;
|
|
1094
|
+
const runSeed = async () => {
|
|
1095
|
+
if (options.seed) await options.seed({ data, identities });
|
|
1096
|
+
};
|
|
1097
|
+
await runSeed();
|
|
1098
|
+
return {
|
|
1099
|
+
app,
|
|
1100
|
+
data,
|
|
1101
|
+
identities,
|
|
1102
|
+
async reset() {
|
|
1103
|
+
await app.reset();
|
|
1104
|
+
await runSeed();
|
|
1105
|
+
},
|
|
1106
|
+
async close() {
|
|
1107
|
+
await app.close();
|
|
1108
|
+
}
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
|
|
983
1112
|
// src/next/headers-shim.ts
|
|
984
1113
|
var import_node_async_hooks = require("async_hooks");
|
|
985
1114
|
var requestScopeStorage = globalSingleton(
|
|
@@ -1077,6 +1206,7 @@ function parseWorkflowName(describeTitle) {
|
|
|
1077
1206
|
TEST_ENV_DEFAULTS,
|
|
1078
1207
|
WORKFLOW_NAME_PREFIX,
|
|
1079
1208
|
callRoute,
|
|
1209
|
+
createModuleApp,
|
|
1080
1210
|
createTestApp,
|
|
1081
1211
|
describeWorkflow,
|
|
1082
1212
|
parseWorkflowName
|
package/dist/index.mjs
CHANGED
|
@@ -54,6 +54,7 @@ var state = globalSingleton("state", () => ({
|
|
|
54
54
|
emailCounter: 0,
|
|
55
55
|
identities: /* @__PURE__ */ new Map(),
|
|
56
56
|
identityLinks: [],
|
|
57
|
+
identityMemory: [],
|
|
57
58
|
allowNetwork: false
|
|
58
59
|
}));
|
|
59
60
|
function requireDb() {
|
|
@@ -711,16 +712,108 @@ async function handleAttachLink(identityId, request) {
|
|
|
711
712
|
state.identityLinks.push(link);
|
|
712
713
|
return success({ link });
|
|
713
714
|
}
|
|
715
|
+
function memoryDto(m) {
|
|
716
|
+
return {
|
|
717
|
+
id: m.id,
|
|
718
|
+
source: m.source,
|
|
719
|
+
kind: m.kind,
|
|
720
|
+
content: m.content,
|
|
721
|
+
metadata: m.metadata,
|
|
722
|
+
createdAt: m.createdAt
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
async function handleResolve(request) {
|
|
726
|
+
const body = await readBody(request);
|
|
727
|
+
const type = body.type;
|
|
728
|
+
if (type !== "person" && type !== "account") {
|
|
729
|
+
return failure("type must be 'person' or 'account'");
|
|
730
|
+
}
|
|
731
|
+
if (type === "account") {
|
|
732
|
+
return failure(
|
|
733
|
+
"resolveOrCreate is for channel-linked persons; an account is not resolvable by link \u2014 create it explicitly instead.",
|
|
734
|
+
409
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
const link = body.link;
|
|
738
|
+
const kind = link?.kind;
|
|
739
|
+
const externalId = link?.externalId;
|
|
740
|
+
if (typeof kind !== "string" || !LINK_KINDS.has(kind)) {
|
|
741
|
+
return failure(`link.kind must be one of: ${[...LINK_KINDS].join(", ")}`);
|
|
742
|
+
}
|
|
743
|
+
if (typeof externalId !== "string" || !externalId) {
|
|
744
|
+
return failure("link.externalId is required");
|
|
745
|
+
}
|
|
746
|
+
const existingLink = state.identityLinks.find(
|
|
747
|
+
(l) => l.kind === kind && l.externalId === externalId
|
|
748
|
+
);
|
|
749
|
+
if (existingLink) {
|
|
750
|
+
const identity2 = state.identities.get(existingLink.identityId);
|
|
751
|
+
if (identity2) return success({ identity: identity2, created: false });
|
|
752
|
+
}
|
|
753
|
+
const identity = {
|
|
754
|
+
id: crypto3.randomUUID(),
|
|
755
|
+
type,
|
|
756
|
+
parentId: null,
|
|
757
|
+
displayName: body.displayName ?? null,
|
|
758
|
+
profile: body.profile ?? {},
|
|
759
|
+
status: "active",
|
|
760
|
+
mergedIntoId: null,
|
|
761
|
+
externalRef: null,
|
|
762
|
+
createdAt: now(),
|
|
763
|
+
updatedAt: now()
|
|
764
|
+
};
|
|
765
|
+
state.identities.set(identity.id, identity);
|
|
766
|
+
state.identityLinks.push({
|
|
767
|
+
id: crypto3.randomUUID(),
|
|
768
|
+
identityId: identity.id,
|
|
769
|
+
kind,
|
|
770
|
+
externalId,
|
|
771
|
+
verified: true,
|
|
772
|
+
createdAt: now()
|
|
773
|
+
});
|
|
774
|
+
return success({ identity, created: true });
|
|
775
|
+
}
|
|
776
|
+
async function handleWriteMemory(identityId, request) {
|
|
777
|
+
if (!state.identities.get(identityId)) return failure("Identity not found", 404);
|
|
778
|
+
const body = await readBody(request);
|
|
779
|
+
if (typeof body.content !== "string" || !body.content) return failure("content is required");
|
|
780
|
+
const entry = {
|
|
781
|
+
id: crypto3.randomUUID(),
|
|
782
|
+
identityId,
|
|
783
|
+
source: typeof body.source === "string" ? body.source : "app",
|
|
784
|
+
kind: typeof body.kind === "string" ? body.kind : "fact",
|
|
785
|
+
content: body.content,
|
|
786
|
+
metadata: body.metadata ?? {},
|
|
787
|
+
createdAt: now()
|
|
788
|
+
};
|
|
789
|
+
state.identityMemory.push(entry);
|
|
790
|
+
return success({ memory: memoryDto(entry) });
|
|
791
|
+
}
|
|
792
|
+
function handleListMemory(identityId, request) {
|
|
793
|
+
if (!state.identities.get(identityId)) return failure("Identity not found", 404);
|
|
794
|
+
let rows = state.identityMemory.filter((m) => m.identityId === identityId).reverse();
|
|
795
|
+
const limitParam = new URL(request.url).searchParams.get("limit");
|
|
796
|
+
if (limitParam) rows = rows.slice(0, Math.max(1, parseInt(limitParam, 10) || 0));
|
|
797
|
+
return success({ memories: rows.map(memoryDto) });
|
|
798
|
+
}
|
|
714
799
|
async function handleIdentitiesRequest(request, subPath) {
|
|
715
800
|
const method = request.method;
|
|
716
801
|
if (subPath === "" || subPath === "/") {
|
|
717
802
|
if (method === "GET") return handleList(request);
|
|
718
803
|
if (method === "POST") return handleCreate(request);
|
|
719
804
|
}
|
|
805
|
+
if (subPath === "/resolve" && method === "POST") {
|
|
806
|
+
return handleResolve(request);
|
|
807
|
+
}
|
|
720
808
|
const linksMatch = subPath.match(/^\/([^/]+)\/links$/);
|
|
721
809
|
if (linksMatch && method === "POST") {
|
|
722
810
|
return handleAttachLink(linksMatch[1], request);
|
|
723
811
|
}
|
|
812
|
+
const memoryMatch = subPath.match(/^\/([^/]+)\/memory$/);
|
|
813
|
+
if (memoryMatch) {
|
|
814
|
+
if (method === "GET") return handleListMemory(memoryMatch[1], request);
|
|
815
|
+
if (method === "POST") return handleWriteMemory(memoryMatch[1], request);
|
|
816
|
+
}
|
|
724
817
|
const singleMatch = subPath.match(/^\/([^/]+)$/);
|
|
725
818
|
if (singleMatch) {
|
|
726
819
|
if (method === "GET") return handleGet(singleMatch[1]);
|
|
@@ -920,6 +1013,7 @@ async function createTestApp(options = {}) {
|
|
|
920
1013
|
state.emailCounter = 0;
|
|
921
1014
|
state.identities.clear();
|
|
922
1015
|
state.identityLinks = [];
|
|
1016
|
+
state.identityMemory = [];
|
|
923
1017
|
},
|
|
924
1018
|
async close() {
|
|
925
1019
|
state.db = null;
|
|
@@ -929,6 +1023,7 @@ async function createTestApp(options = {}) {
|
|
|
929
1023
|
state.emails = [];
|
|
930
1024
|
state.identities.clear();
|
|
931
1025
|
state.identityLinks = [];
|
|
1026
|
+
state.identityMemory = [];
|
|
932
1027
|
uninstallFetchRouter();
|
|
933
1028
|
await db.close();
|
|
934
1029
|
}
|
|
@@ -936,6 +1031,42 @@ async function createTestApp(options = {}) {
|
|
|
936
1031
|
return app;
|
|
937
1032
|
}
|
|
938
1033
|
|
|
1034
|
+
// src/module-app.ts
|
|
1035
|
+
import {
|
|
1036
|
+
makeSqlPort,
|
|
1037
|
+
renderSchemaOpsToSql
|
|
1038
|
+
} from "@stardeck-customer-apps/core";
|
|
1039
|
+
async function createModuleApp(options) {
|
|
1040
|
+
const schemaSql = options.modules.map((m) => renderSchemaOpsToSql(m.schema)).join("\n\n");
|
|
1041
|
+
const app = await createTestApp({ schemaSql, allowNetwork: options.allowNetwork });
|
|
1042
|
+
const sql = { query: (text, params) => app.db.query(text, params ?? []) };
|
|
1043
|
+
const data = makeSqlPort(sql);
|
|
1044
|
+
const { createIntegrationsClient } = await import("@stardeck-customer-apps/integrations-sdk");
|
|
1045
|
+
const identities = createIntegrationsClient({
|
|
1046
|
+
controlPlaneUrl: TEST_ENV_DEFAULTS.CONTROL_PLANE_URL,
|
|
1047
|
+
organizationId: TEST_ENV_DEFAULTS.ORGANIZATION_ID,
|
|
1048
|
+
projectId: TEST_ENV_DEFAULTS.PROJECT_ID,
|
|
1049
|
+
deploymentId: TEST_ENV_DEFAULTS.DEPLOYMENT_ID,
|
|
1050
|
+
deploymentSecret: TEST_ENV_DEFAULTS.DEPLOYMENT_SECRET
|
|
1051
|
+
}).identities;
|
|
1052
|
+
const runSeed = async () => {
|
|
1053
|
+
if (options.seed) await options.seed({ data, identities });
|
|
1054
|
+
};
|
|
1055
|
+
await runSeed();
|
|
1056
|
+
return {
|
|
1057
|
+
app,
|
|
1058
|
+
data,
|
|
1059
|
+
identities,
|
|
1060
|
+
async reset() {
|
|
1061
|
+
await app.reset();
|
|
1062
|
+
await runSeed();
|
|
1063
|
+
},
|
|
1064
|
+
async close() {
|
|
1065
|
+
await app.close();
|
|
1066
|
+
}
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
|
|
939
1070
|
// src/next/headers-shim.ts
|
|
940
1071
|
import { AsyncLocalStorage } from "async_hooks";
|
|
941
1072
|
var requestScopeStorage = globalSingleton(
|
|
@@ -1032,6 +1163,7 @@ export {
|
|
|
1032
1163
|
TEST_ENV_DEFAULTS,
|
|
1033
1164
|
WORKFLOW_NAME_PREFIX,
|
|
1034
1165
|
callRoute,
|
|
1166
|
+
createModuleApp,
|
|
1035
1167
|
createTestApp,
|
|
1036
1168
|
describeWorkflow,
|
|
1037
1169
|
parseWorkflowName
|
package/dist/setup.js
CHANGED
|
@@ -40,6 +40,7 @@ var state = globalSingleton("state", () => ({
|
|
|
40
40
|
emailCounter: 0,
|
|
41
41
|
identities: /* @__PURE__ */ new Map(),
|
|
42
42
|
identityLinks: [],
|
|
43
|
+
identityMemory: [],
|
|
43
44
|
allowNetwork: false
|
|
44
45
|
}));
|
|
45
46
|
function requireDb() {
|
|
@@ -687,16 +688,108 @@ async function handleAttachLink(identityId, request) {
|
|
|
687
688
|
state.identityLinks.push(link);
|
|
688
689
|
return success({ link });
|
|
689
690
|
}
|
|
691
|
+
function memoryDto(m) {
|
|
692
|
+
return {
|
|
693
|
+
id: m.id,
|
|
694
|
+
source: m.source,
|
|
695
|
+
kind: m.kind,
|
|
696
|
+
content: m.content,
|
|
697
|
+
metadata: m.metadata,
|
|
698
|
+
createdAt: m.createdAt
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
async function handleResolve(request) {
|
|
702
|
+
const body = await readBody(request);
|
|
703
|
+
const type = body.type;
|
|
704
|
+
if (type !== "person" && type !== "account") {
|
|
705
|
+
return failure("type must be 'person' or 'account'");
|
|
706
|
+
}
|
|
707
|
+
if (type === "account") {
|
|
708
|
+
return failure(
|
|
709
|
+
"resolveOrCreate is for channel-linked persons; an account is not resolvable by link \u2014 create it explicitly instead.",
|
|
710
|
+
409
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
const link = body.link;
|
|
714
|
+
const kind = link?.kind;
|
|
715
|
+
const externalId = link?.externalId;
|
|
716
|
+
if (typeof kind !== "string" || !LINK_KINDS.has(kind)) {
|
|
717
|
+
return failure(`link.kind must be one of: ${[...LINK_KINDS].join(", ")}`);
|
|
718
|
+
}
|
|
719
|
+
if (typeof externalId !== "string" || !externalId) {
|
|
720
|
+
return failure("link.externalId is required");
|
|
721
|
+
}
|
|
722
|
+
const existingLink = state.identityLinks.find(
|
|
723
|
+
(l) => l.kind === kind && l.externalId === externalId
|
|
724
|
+
);
|
|
725
|
+
if (existingLink) {
|
|
726
|
+
const identity2 = state.identities.get(existingLink.identityId);
|
|
727
|
+
if (identity2) return success({ identity: identity2, created: false });
|
|
728
|
+
}
|
|
729
|
+
const identity = {
|
|
730
|
+
id: import_node_crypto3.default.randomUUID(),
|
|
731
|
+
type,
|
|
732
|
+
parentId: null,
|
|
733
|
+
displayName: body.displayName ?? null,
|
|
734
|
+
profile: body.profile ?? {},
|
|
735
|
+
status: "active",
|
|
736
|
+
mergedIntoId: null,
|
|
737
|
+
externalRef: null,
|
|
738
|
+
createdAt: now(),
|
|
739
|
+
updatedAt: now()
|
|
740
|
+
};
|
|
741
|
+
state.identities.set(identity.id, identity);
|
|
742
|
+
state.identityLinks.push({
|
|
743
|
+
id: import_node_crypto3.default.randomUUID(),
|
|
744
|
+
identityId: identity.id,
|
|
745
|
+
kind,
|
|
746
|
+
externalId,
|
|
747
|
+
verified: true,
|
|
748
|
+
createdAt: now()
|
|
749
|
+
});
|
|
750
|
+
return success({ identity, created: true });
|
|
751
|
+
}
|
|
752
|
+
async function handleWriteMemory(identityId, request) {
|
|
753
|
+
if (!state.identities.get(identityId)) return failure("Identity not found", 404);
|
|
754
|
+
const body = await readBody(request);
|
|
755
|
+
if (typeof body.content !== "string" || !body.content) return failure("content is required");
|
|
756
|
+
const entry = {
|
|
757
|
+
id: import_node_crypto3.default.randomUUID(),
|
|
758
|
+
identityId,
|
|
759
|
+
source: typeof body.source === "string" ? body.source : "app",
|
|
760
|
+
kind: typeof body.kind === "string" ? body.kind : "fact",
|
|
761
|
+
content: body.content,
|
|
762
|
+
metadata: body.metadata ?? {},
|
|
763
|
+
createdAt: now()
|
|
764
|
+
};
|
|
765
|
+
state.identityMemory.push(entry);
|
|
766
|
+
return success({ memory: memoryDto(entry) });
|
|
767
|
+
}
|
|
768
|
+
function handleListMemory(identityId, request) {
|
|
769
|
+
if (!state.identities.get(identityId)) return failure("Identity not found", 404);
|
|
770
|
+
let rows = state.identityMemory.filter((m) => m.identityId === identityId).reverse();
|
|
771
|
+
const limitParam = new URL(request.url).searchParams.get("limit");
|
|
772
|
+
if (limitParam) rows = rows.slice(0, Math.max(1, parseInt(limitParam, 10) || 0));
|
|
773
|
+
return success({ memories: rows.map(memoryDto) });
|
|
774
|
+
}
|
|
690
775
|
async function handleIdentitiesRequest(request, subPath) {
|
|
691
776
|
const method = request.method;
|
|
692
777
|
if (subPath === "" || subPath === "/") {
|
|
693
778
|
if (method === "GET") return handleList(request);
|
|
694
779
|
if (method === "POST") return handleCreate(request);
|
|
695
780
|
}
|
|
781
|
+
if (subPath === "/resolve" && method === "POST") {
|
|
782
|
+
return handleResolve(request);
|
|
783
|
+
}
|
|
696
784
|
const linksMatch = subPath.match(/^\/([^/]+)\/links$/);
|
|
697
785
|
if (linksMatch && method === "POST") {
|
|
698
786
|
return handleAttachLink(linksMatch[1], request);
|
|
699
787
|
}
|
|
788
|
+
const memoryMatch = subPath.match(/^\/([^/]+)\/memory$/);
|
|
789
|
+
if (memoryMatch) {
|
|
790
|
+
if (method === "GET") return handleListMemory(memoryMatch[1], request);
|
|
791
|
+
if (method === "POST") return handleWriteMemory(memoryMatch[1], request);
|
|
792
|
+
}
|
|
700
793
|
const singleMatch = subPath.match(/^\/([^/]+)$/);
|
|
701
794
|
if (singleMatch) {
|
|
702
795
|
if (method === "GET") return handleGet(singleMatch[1]);
|
package/dist/setup.mjs
CHANGED
|
@@ -16,6 +16,7 @@ var state = globalSingleton("state", () => ({
|
|
|
16
16
|
emailCounter: 0,
|
|
17
17
|
identities: /* @__PURE__ */ new Map(),
|
|
18
18
|
identityLinks: [],
|
|
19
|
+
identityMemory: [],
|
|
19
20
|
allowNetwork: false
|
|
20
21
|
}));
|
|
21
22
|
function requireDb() {
|
|
@@ -663,16 +664,108 @@ async function handleAttachLink(identityId, request) {
|
|
|
663
664
|
state.identityLinks.push(link);
|
|
664
665
|
return success({ link });
|
|
665
666
|
}
|
|
667
|
+
function memoryDto(m) {
|
|
668
|
+
return {
|
|
669
|
+
id: m.id,
|
|
670
|
+
source: m.source,
|
|
671
|
+
kind: m.kind,
|
|
672
|
+
content: m.content,
|
|
673
|
+
metadata: m.metadata,
|
|
674
|
+
createdAt: m.createdAt
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
async function handleResolve(request) {
|
|
678
|
+
const body = await readBody(request);
|
|
679
|
+
const type = body.type;
|
|
680
|
+
if (type !== "person" && type !== "account") {
|
|
681
|
+
return failure("type must be 'person' or 'account'");
|
|
682
|
+
}
|
|
683
|
+
if (type === "account") {
|
|
684
|
+
return failure(
|
|
685
|
+
"resolveOrCreate is for channel-linked persons; an account is not resolvable by link \u2014 create it explicitly instead.",
|
|
686
|
+
409
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
const link = body.link;
|
|
690
|
+
const kind = link?.kind;
|
|
691
|
+
const externalId = link?.externalId;
|
|
692
|
+
if (typeof kind !== "string" || !LINK_KINDS.has(kind)) {
|
|
693
|
+
return failure(`link.kind must be one of: ${[...LINK_KINDS].join(", ")}`);
|
|
694
|
+
}
|
|
695
|
+
if (typeof externalId !== "string" || !externalId) {
|
|
696
|
+
return failure("link.externalId is required");
|
|
697
|
+
}
|
|
698
|
+
const existingLink = state.identityLinks.find(
|
|
699
|
+
(l) => l.kind === kind && l.externalId === externalId
|
|
700
|
+
);
|
|
701
|
+
if (existingLink) {
|
|
702
|
+
const identity2 = state.identities.get(existingLink.identityId);
|
|
703
|
+
if (identity2) return success({ identity: identity2, created: false });
|
|
704
|
+
}
|
|
705
|
+
const identity = {
|
|
706
|
+
id: crypto3.randomUUID(),
|
|
707
|
+
type,
|
|
708
|
+
parentId: null,
|
|
709
|
+
displayName: body.displayName ?? null,
|
|
710
|
+
profile: body.profile ?? {},
|
|
711
|
+
status: "active",
|
|
712
|
+
mergedIntoId: null,
|
|
713
|
+
externalRef: null,
|
|
714
|
+
createdAt: now(),
|
|
715
|
+
updatedAt: now()
|
|
716
|
+
};
|
|
717
|
+
state.identities.set(identity.id, identity);
|
|
718
|
+
state.identityLinks.push({
|
|
719
|
+
id: crypto3.randomUUID(),
|
|
720
|
+
identityId: identity.id,
|
|
721
|
+
kind,
|
|
722
|
+
externalId,
|
|
723
|
+
verified: true,
|
|
724
|
+
createdAt: now()
|
|
725
|
+
});
|
|
726
|
+
return success({ identity, created: true });
|
|
727
|
+
}
|
|
728
|
+
async function handleWriteMemory(identityId, request) {
|
|
729
|
+
if (!state.identities.get(identityId)) return failure("Identity not found", 404);
|
|
730
|
+
const body = await readBody(request);
|
|
731
|
+
if (typeof body.content !== "string" || !body.content) return failure("content is required");
|
|
732
|
+
const entry = {
|
|
733
|
+
id: crypto3.randomUUID(),
|
|
734
|
+
identityId,
|
|
735
|
+
source: typeof body.source === "string" ? body.source : "app",
|
|
736
|
+
kind: typeof body.kind === "string" ? body.kind : "fact",
|
|
737
|
+
content: body.content,
|
|
738
|
+
metadata: body.metadata ?? {},
|
|
739
|
+
createdAt: now()
|
|
740
|
+
};
|
|
741
|
+
state.identityMemory.push(entry);
|
|
742
|
+
return success({ memory: memoryDto(entry) });
|
|
743
|
+
}
|
|
744
|
+
function handleListMemory(identityId, request) {
|
|
745
|
+
if (!state.identities.get(identityId)) return failure("Identity not found", 404);
|
|
746
|
+
let rows = state.identityMemory.filter((m) => m.identityId === identityId).reverse();
|
|
747
|
+
const limitParam = new URL(request.url).searchParams.get("limit");
|
|
748
|
+
if (limitParam) rows = rows.slice(0, Math.max(1, parseInt(limitParam, 10) || 0));
|
|
749
|
+
return success({ memories: rows.map(memoryDto) });
|
|
750
|
+
}
|
|
666
751
|
async function handleIdentitiesRequest(request, subPath) {
|
|
667
752
|
const method = request.method;
|
|
668
753
|
if (subPath === "" || subPath === "/") {
|
|
669
754
|
if (method === "GET") return handleList(request);
|
|
670
755
|
if (method === "POST") return handleCreate(request);
|
|
671
756
|
}
|
|
757
|
+
if (subPath === "/resolve" && method === "POST") {
|
|
758
|
+
return handleResolve(request);
|
|
759
|
+
}
|
|
672
760
|
const linksMatch = subPath.match(/^\/([^/]+)\/links$/);
|
|
673
761
|
if (linksMatch && method === "POST") {
|
|
674
762
|
return handleAttachLink(linksMatch[1], request);
|
|
675
763
|
}
|
|
764
|
+
const memoryMatch = subPath.match(/^\/([^/]+)\/memory$/);
|
|
765
|
+
if (memoryMatch) {
|
|
766
|
+
if (method === "GET") return handleListMemory(memoryMatch[1], request);
|
|
767
|
+
if (method === "POST") return handleWriteMemory(memoryMatch[1], request);
|
|
768
|
+
}
|
|
676
769
|
const singleMatch = subPath.match(/^\/([^/]+)$/);
|
|
677
770
|
if (singleMatch) {
|
|
678
771
|
if (method === "GET") return handleGet(singleMatch[1]);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stardeck-customer-apps/testing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Vitest test harness for Stardeck customer apps — in-process Postgres (PGlite) plus a control-plane simulator so the real Stardeck SDKs run unmodified in tests",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.mjs",
|
|
@@ -62,13 +62,18 @@
|
|
|
62
62
|
"author": "Stardeck",
|
|
63
63
|
"license": "MIT",
|
|
64
64
|
"dependencies": {
|
|
65
|
-
"@electric-sql/pglite": "^0.3.0"
|
|
65
|
+
"@electric-sql/pglite": "^0.3.0",
|
|
66
|
+
"@stardeck-customer-apps/core": "*"
|
|
66
67
|
},
|
|
67
68
|
"peerDependencies": {
|
|
69
|
+
"@stardeck-customer-apps/integrations-sdk": ">=1.6.0",
|
|
68
70
|
"next": "^14.0.0 || ^15.0.0 || ^16.0.0",
|
|
69
71
|
"vitest": ">=2.0.0"
|
|
70
72
|
},
|
|
71
73
|
"peerDependenciesMeta": {
|
|
74
|
+
"@stardeck-customer-apps/integrations-sdk": {
|
|
75
|
+
"optional": true
|
|
76
|
+
},
|
|
72
77
|
"next": {
|
|
73
78
|
"optional": true
|
|
74
79
|
}
|