Skip to content
Draft
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
1 change: 1 addition & 0 deletions NOTICE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ This Software contains code from the following open source projects:
| [@opentelemetry/api](https://www.npmjs.com/package/@opentelemetry/api) | 1.9.0 | Apache-2.0 | https://github.com/open-telemetry/opentelemetry-js/tree/main/api |
| [@opentelemetry/api-logs](https://www.npmjs.com/package/@opentelemetry/api-logs) | 0.219.0 | Apache-2.0 | https://github.com/open-telemetry/opentelemetry-js/tree/main/experimental/packages/api-logs |
| [@opentelemetry/auto-instrumentations-node](https://www.npmjs.com/package/@opentelemetry/auto-instrumentations-node) | 0.77.0 | Apache-2.0 | https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/packages/auto-instrumentations-node#readme |
| [@opentelemetry/core](https://www.npmjs.com/package/@opentelemetry/core) | 2.8.0 | Apache-2.0 | https://github.com/open-telemetry/opentelemetry-js/tree/main/packages/opentelemetry-core |
| [@opentelemetry/exporter-logs-otlp-proto](https://www.npmjs.com/package/@opentelemetry/exporter-logs-otlp-proto) | 0.219.0 | Apache-2.0 | https://github.com/open-telemetry/opentelemetry-js/tree/main/experimental/packages/exporter-logs-otlp-proto |
| [@opentelemetry/exporter-metrics-otlp-proto](https://www.npmjs.com/package/@opentelemetry/exporter-metrics-otlp-proto) | 0.219.0 | Apache-2.0 | https://github.com/open-telemetry/opentelemetry-js/tree/main/experimental/packages/opentelemetry-exporter-metrics-otlp-proto |
| [@opentelemetry/exporter-trace-otlp-proto](https://www.npmjs.com/package/@opentelemetry/exporter-trace-otlp-proto) | 0.219.0 | Apache-2.0 | https://github.com/open-telemetry/opentelemetry-js/tree/main/experimental/packages/exporter-trace-otlp-proto |
Expand Down
1 change: 1 addition & 0 deletions packages/appkit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@
"@opentelemetry/api": "1.9.0",
"@opentelemetry/api-logs": "0.219.0",
"@opentelemetry/auto-instrumentations-node": "0.77.0",
"@opentelemetry/core": "2.8.0",
"@opentelemetry/exporter-logs-otlp-proto": "0.219.0",
"@opentelemetry/exporter-metrics-otlp-proto": "0.219.0",
"@opentelemetry/exporter-trace-otlp-proto": "0.219.0",
Expand Down
9 changes: 9 additions & 0 deletions packages/appkit/src/core/appkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,15 @@ export class AppKit<TPlugins extends InputPluginMap> {
});
}
}

const serviceApi: Record<string, unknown> = Object.create(null);
for (const name of Object.keys(this.#pluginInstances)) {
Object.defineProperty(serviceApi, name, {
enumerable: true,
get: () => this.wrapWithAsUser(this.#pluginInstances[name]),
});
}
this.#context.registerServiceApi(Object.freeze(serviceApi));
}

private createAndRegisterPlugin<T extends PluginConstructor>(
Expand Down
18 changes: 18 additions & 0 deletions packages/appkit/src/core/plugin-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export class PluginContext {
LifecycleEvent,
Set<() => void | Promise<void>>
>();
private serviceApi: Readonly<Record<string, unknown>> | undefined;
private telemetry: ITelemetry;
private createCallerContext: typeof ServiceContext.createCallerContext;

Expand Down Expand Up @@ -185,6 +186,23 @@ export class PluginContext {
this.plugins.set(name, instance);
}

/**
* Publish the service-principal handle: each plugin's exports, read the way
* `appkit.<name>` reads them.
* @internal Called once by AppKit core after every plugin is registered.
*/
registerServiceApi(api: Readonly<Record<string, unknown>>): void {
this.serviceApi = api;
}

/**
* The service-principal handle keyed by plugin name, for plugins that run
* application code against sibling plugins. `undefined` outside AppKit core.
*/
getServiceApi(): Readonly<Record<string, unknown>> | undefined {
return this.serviceApi;
}

/**
* Returns all registered plugin instances keyed by name.
* Used by the server plugin for route injection, client config,
Expand Down
51 changes: 51 additions & 0 deletions packages/appkit/src/core/tests/service-api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, test } from "vitest";

import { getCurrentPrincipalKey } from "../../context";
import { Plugin, toPlugin } from "../../plugin";
import { createTestApp } from "../../testing";

const manifest = {
displayName: "Probe",
description: "Service handle probe",
resources: { required: [], optional: [] },
};

class LedgerPlugin extends Plugin {
static manifest = { ...manifest, name: "ledger" as const };
private entries = 0;
record() {
this.entries += 1;
return `${getCurrentPrincipalKey()}:${this.entries}`;
}
exports() {
return { record: this.record };
}
}

class RunnerPlugin extends Plugin {
static manifest = { ...manifest, name: "runner" as const };
/** What a plugin running application code would hand that code. */
serviceApi() {
return this.context?.getServiceApi();
}
exports() {
return { serviceApi: () => this.serviceApi() };
}
}

describe("PluginContext service handle", () => {
test("lets a plugin reach every sibling's service-principal exports", async () => {
await using app = await createTestApp({
plugins: [toPlugin(LedgerPlugin)(), toPlugin(RunnerPlugin)()],
server: false,
});

const api = app.plugins.runner.serviceApi() as Record<string, any>;

expect(Object.keys(api).sort()).toEqual(["ledger", "runner"]);
expect(Object.isFrozen(api)).toBe(true);
// The same bound handle `appkit.ledger` returns, in service scope.
expect(api.ledger.record()).toBe("app:1");
expect(app.plugins.ledger.record()).toBe("app:2");
});
});
6 changes: 5 additions & 1 deletion packages/appkit/src/database/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ const definitions: Record<
DatabaseErrorCategory,
{ readonly message: string; readonly statusCode: number }
> = {
UNAUTHENTICATED: {
message: "Database request is not authenticated",
statusCode: 401,
},
INVALID_REQUEST: { message: "Invalid database request", statusCode: 400 },
VALIDATION_FAILED: {
message: "Database request failed validation",
Expand All @@ -42,7 +46,7 @@ const definitions: Record<
statusCode: 415,
},
PAYLOAD_TOO_LARGE: {
message: "Database response is too large",
message: "Database payload is too large",
statusCode: 413,
},
INTERNAL: { message: "Database operation failed", statusCode: 500 },
Expand Down
23 changes: 22 additions & 1 deletion packages/appkit/src/plugins/database/crud/codecs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,25 @@ export interface CompiledColumn {
/** Decimal integer with no sign padding, leading zeros, or exponent. */
const DECIMAL_INT = /^-?(0|[1-9]\d*)$/;

/**
* PostgreSQL's ISO text output for a timestamp, such as
* `2024-01-01 10:00:00.5` or `2024-01-01 10:00:00+05:30`.
*/
const PG_TIMESTAMP =
/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}(?:\.\d+)?)(?:([+-]\d{2})(?::(\d{2}))?)?$/;

/**
* Answer a timestamp in RFC 3339 form, which browsers parse and the decoder
* accepts back: a `T` between date and time, and a `±HH:MM` offset. Any other
* text, such as `infinity`, is answered as the driver gave it.
*/
function canonicalTimestamp(value: string): string {
const match = PG_TIMESTAMP.exec(value);
if (!match) return value;
const [, date, time, hours, minutes = "00"] = match;
return `${date}T${time}${hours === undefined ? "" : `${hours}:${minutes}`}`;
}

/**
* Map a wire value onto the runtime representation the schema validators and
* Drizzle columns expect. Only path segments and JSON scalars reach this, so
Expand Down Expand Up @@ -75,9 +94,11 @@ function encodeValue(meta: ColumnMeta, value: unknown): JsonValue {
case "string":
case "uuid":
case "enum":
case "date":
if (typeof value === "string") return value;
break;
case "date":
if (typeof value === "string") return canonicalTimestamp(value);
break;
case "json":
if (isJsonValue(value)) return value;
break;
Expand Down
17 changes: 17 additions & 0 deletions packages/appkit/src/plugins/database/crud/tests/codecs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,23 @@ describe("compileColumn encode", () => {
expect(column("name").encode(undefined)).toBeNull();
});

it("answers PostgreSQL timestamp text in a form the decoder accepts back", () => {
const createdAt = column("createdAt");
const samples: [string, string][] = [
["2024-01-01 10:00:00", "2024-01-01T10:00:00"],
["2024-01-01 10:00:00.123456", "2024-01-01T10:00:00.123456"],
["2024-01-01 10:00:00+00", "2024-01-01T10:00:00+00:00"],
["2024-01-01 10:00:00.5-03", "2024-01-01T10:00:00.5-03:00"],
["2024-01-01 10:00:00+05:30", "2024-01-01T10:00:00+05:30"],
];
for (const [text, wire] of samples) {
expect(createdAt.encode(text)).toBe(wire);
expect(createdAt.decode(createdAt.encode(text))).toBe(wire);
}
// Text that is not an ISO timestamp is answered as the driver gave it.
expect(createdAt.encode("infinity")).toBe("infinity");
});

it("fails closed when a driver value contradicts its column", () => {
expect(() => column("name").encode(7)).toThrow(DatabasePluginError);
expect(() => column("createdAt").encode(new Date())).toThrow(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ describe("projection and response limits", () => {
);
expect(response.sent.status).toBe(413);
expect(response.json()).toEqual({
error: "Database response is too large",
error: "Database payload is too large",
});
expect(MAX_RESPONSE_BYTES).toBeLessThan(6 * wide.length);
});
Expand All @@ -364,7 +364,7 @@ describe("projection and response limits", () => {
);
expect(response.sent.status).toBe(413);
expect(response.json()).toEqual({
error: "Database response is too large",
error: "Database payload is too large",
});
expect(touchedSentinel).toBe(false);
});
Expand Down
2 changes: 2 additions & 0 deletions packages/appkit/src/plugins/database/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export const TRANSACTION_TIMEOUT_MS = 30_000;
// Request decoding: rejected before any database work is scheduled.
/** Max encoded size of a generated read query string. */
export const MAX_QUERY_BYTES = 8 * 1024;
/** Max UTF-8 size of one operation request body. */
export const MAX_OPERATION_BODY_BYTES = 1024 * 1024;
/** Max nesting depth of `and`/`or` groups in one generated filter. */
export const MAX_WHERE_DEPTH = 5;
/** Max column conditions across one generated filter. */
Expand Down
Loading
Loading