@softure-ai/core 0.0.0-stage → 0.1.6
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 +13 -0
- package/LICENSE +21 -0
- package/README.md +277 -2
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +4 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/load-config.d.ts +49 -0
- package/dist/cli/load-config.d.ts.map +1 -0
- package/dist/cli/load-config.js +73 -0
- package/dist/cli/load-config.js.map +1 -0
- package/dist/clock.d.ts +15 -0
- package/dist/clock.d.ts.map +1 -0
- package/dist/clock.js +26 -0
- package/dist/clock.js.map +1 -0
- package/dist/config-error.d.ts +12 -0
- package/dist/config-error.d.ts.map +1 -0
- package/dist/config-error.js +20 -0
- package/dist/config-error.js.map +1 -0
- package/dist/config.d.ts +62 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +168 -0
- package/dist/config.js.map +1 -0
- package/dist/database-requirement.d.ts +9 -0
- package/dist/database-requirement.d.ts.map +1 -0
- package/dist/database-requirement.js +26 -0
- package/dist/database-requirement.js.map +1 -0
- package/dist/i18n.d.ts +37 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.js +63 -0
- package/dist/i18n.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/manifest.d.ts +30 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +48 -0
- package/dist/manifest.js.map +1 -0
- package/dist/messages/en.d.ts +7 -0
- package/dist/messages/en.d.ts.map +1 -0
- package/dist/messages/en.js +7 -0
- package/dist/messages/en.js.map +1 -0
- package/dist/messages/index.d.ts +6 -0
- package/dist/messages/index.d.ts.map +1 -0
- package/dist/messages/index.js +5 -0
- package/dist/messages/index.js.map +1 -0
- package/dist/messages/pl.d.ts +3 -0
- package/dist/messages/pl.d.ts.map +1 -0
- package/dist/messages/pl.js +7 -0
- package/dist/messages/pl.js.map +1 -0
- package/dist/module.d.ts +102 -0
- package/dist/module.d.ts.map +1 -0
- package/dist/module.js +144 -0
- package/dist/module.js.map +1 -0
- package/dist/next/index.d.ts +2 -0
- package/dist/next/index.d.ts.map +1 -0
- package/dist/next/index.js +4 -0
- package/dist/next/index.js.map +1 -0
- package/dist/next/registry.d.ts +8 -0
- package/dist/next/registry.d.ts.map +1 -0
- package/dist/next/registry.js +20 -0
- package/dist/next/registry.js.map +1 -0
- package/dist/result.d.ts +17 -0
- package/dist/result.d.ts.map +1 -0
- package/dist/result.js +10 -0
- package/dist/result.js.map +1 -0
- package/dist/safe-error.d.ts +32 -0
- package/dist/safe-error.d.ts.map +1 -0
- package/dist/safe-error.js +68 -0
- package/dist/safe-error.js.map +1 -0
- package/dist/site-urls.d.ts +14 -0
- package/dist/site-urls.d.ts.map +1 -0
- package/dist/site-urls.js +26 -0
- package/dist/site-urls.js.map +1 -0
- package/dist/switches.d.ts +22 -0
- package/dist/switches.d.ts.map +1 -0
- package/dist/switches.js +19 -0
- package/dist/switches.js.map +1 -0
- package/dist/version-range.d.ts +14 -0
- package/dist/version-range.d.ts.map +1 -0
- package/dist/version-range.js +70 -0
- package/dist/version-range.js.map +1 -0
- package/package.json +48 -4
- package/src/cli/index.ts +15 -0
- package/src/cli/load-config.ts +106 -0
- package/src/clock.ts +42 -0
- package/src/config-error.ts +26 -0
- package/src/config.ts +244 -0
- package/src/database-requirement.ts +28 -0
- package/src/i18n.ts +99 -0
- package/src/index.ts +50 -0
- package/src/manifest.ts +56 -0
- package/src/messages/en.ts +6 -0
- package/src/messages/index.ts +8 -0
- package/src/messages/pl.ts +8 -0
- package/src/module.ts +249 -0
- package/src/next/index.ts +3 -0
- package/src/next/registry.ts +38 -0
- package/src/result.ts +29 -0
- package/src/safe-error.ts +80 -0
- package/src/site-urls.ts +40 -0
- package/src/switches.ts +35 -0
- package/src/version-range.ts +90 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Newest first. Each version lists what changed for an app that uses `@softure-ai/core`. When an app has run a version in
|
|
4
|
+
production, the version gets a line `verified in: <app>@<commit>` ([docs/05](../../docs/05-adoption-playbook.md),
|
|
5
|
+
"Definition of done"). Versions before the first one below are described in their GitHub Releases (`core@x.y.z`).
|
|
6
|
+
|
|
7
|
+
## 0.1.6
|
|
8
|
+
|
|
9
|
+
- `PublicError`, `isPublicError` and `getPublicMessage`: a message written for the user passes through, every other error's text stays out (`safeError` is unchanged).
|
|
10
|
+
- `selectPlural` builds one `Intl.PluralRules` per locale and reuses it.
|
|
11
|
+
- `database: { url, handle }`: the config can carry the app's own database handle, shared with the modules.
|
|
12
|
+
- An empty `database.url` is accepted when the config is defined and refused only by a command that connects (build stages need no `DATABASE_URL`).
|
|
13
|
+
- A guard against loading the package as CommonJS (ESM only).
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SOFTURE
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,278 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @softure-ai/core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The contract every SOFTURE module stands on. Standard:
|
|
4
|
+
[docs/02-module-standard.md](../../docs/02-module-standard.md).
|
|
5
|
+
|
|
6
|
+
## 1. What it provides
|
|
7
|
+
|
|
8
|
+
The app configuration (`defineSoftureConfig`), the module contract (`defineModule`), `Result`
|
|
9
|
+
with namespaced error codes, an injectable `Clock`, `pl`/`en` messages with partial overrides,
|
|
10
|
+
and `safeError` with `PublicError`. `@softure-ai/core/cli` loads the app's config for the modules' command-line tools.
|
|
11
|
+
|
|
12
|
+
## 2. Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install @softure-ai/core
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Node ≥ 22, ESM only. The only runtime dependency is `zod`.
|
|
19
|
+
|
|
20
|
+
## 3. Configuration
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
interface SoftureConfigInput {
|
|
24
|
+
database?: { // required once any module has a dbSchema
|
|
25
|
+
url: string;
|
|
26
|
+
handle?: () => DatabaseHandle | Promise<DatabaseHandle>; // the app's own handle, see below
|
|
27
|
+
} | null;
|
|
28
|
+
locale: "en" | "pl";
|
|
29
|
+
timezone: string; // IANA zone, e.g. "Europe/Warsaw"
|
|
30
|
+
appOrigin: string; // http(s) origin without a path
|
|
31
|
+
modules: SoftureModule[]; // a module is enabled by being listed
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
// softure.config.ts
|
|
37
|
+
import { defineSoftureConfig } from "@softure-ai/core";
|
|
38
|
+
import { registerSoftureConfig } from "@softure-ai/core/next";
|
|
39
|
+
import { notes } from "@softure-ai/notes";
|
|
40
|
+
|
|
41
|
+
const config = defineSoftureConfig({
|
|
42
|
+
database: { url: process.env.DATABASE_URL ?? "" },
|
|
43
|
+
locale: "pl",
|
|
44
|
+
timezone: "Europe/Warsaw",
|
|
45
|
+
appOrigin: process.env.APP_ORIGIN ?? "http://localhost:3000",
|
|
46
|
+
modules: [notes({ limit: 10, routes: { list: "/my-notes" }, messages: { en: { list: { title: "My notes" } } } })],
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
registerSoftureConfig(config);
|
|
50
|
+
export default config;
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`defineSoftureConfig` throws `SoftureConfigError` with every problem listed (`issues`): an
|
|
54
|
+
unknown locale or zone, an origin with a path, a module listed twice, two modules on one database
|
|
55
|
+
schema, a required dependency missing, a dependency outside its version range (also an optional
|
|
56
|
+
one, when listed), a dependency cycle, or a module with a `dbSchema` and no `database`.
|
|
57
|
+
|
|
58
|
+
An empty `database.url` is not one of those problems. `next build`, a Docker builder stage running
|
|
59
|
+
`--export-migrations` and other build steps import the config without `DATABASE_URL`, so
|
|
60
|
+
`DATABASE_URL ?? ""` defines a valid config there; the first connection (`createDatabase` and
|
|
61
|
+
`getSharedDatabase` in `@softure-ai/db`, `softure migrate`, the module CLIs and the health route)
|
|
62
|
+
refuses the empty URL and names the setting. No placeholder URL is needed for a build.
|
|
63
|
+
|
|
64
|
+
`database.handle` is for an app that already has its own database client: every module, the
|
|
65
|
+
health route and the package commands then use that one handle instead of opening a second one on
|
|
66
|
+
`url` (with `pglite://`, a second instance on the same directory corrupts it). It is a function,
|
|
67
|
+
called on first use and never by `defineSoftureConfig`, and it must return the app's process-wide
|
|
68
|
+
handle; wrap a client with `createPostgresHandle(pool)` or `createPgliteHandle(client)`. Details and
|
|
69
|
+
the alternative (the app builds on the shared handle): `@softure-ai/db` README §3, "One handle per
|
|
70
|
+
process".
|
|
71
|
+
|
|
72
|
+
A command that never connects loads the config inside `withDatabaseOptional(() => import(...))`:
|
|
73
|
+
there a missing, `null` or empty `database.url` gives `database: null` instead of a refusal, also when
|
|
74
|
+
a module has a `dbSchema`; a real URL is kept and every other check still runs. The flag is scoped to
|
|
75
|
+
that one load, and Node keeps a module's first evaluation, so a process loads the config one way.
|
|
76
|
+
|
|
77
|
+
`getModule(config, id)` finds an enabled module; `sortModulesByDependencies(modules)` returns the
|
|
78
|
+
migration order (dependencies first, listed order otherwise).
|
|
79
|
+
|
|
80
|
+
**Defining a module** (in a module package):
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
import { defineModule, resolveMigrationsDir } from "@softure-ai/core";
|
|
84
|
+
import { z } from "zod";
|
|
85
|
+
import { en } from "./messages/en.js";
|
|
86
|
+
import { pl } from "./messages/pl.js";
|
|
87
|
+
|
|
88
|
+
export const notes = defineModule({
|
|
89
|
+
manifest: {
|
|
90
|
+
id: "notes", version: "0.1.0", dependsOn: { auth: "^0.1.0" }, dbSchema: "notes",
|
|
91
|
+
tables: ["notes"], env: [], switches: ["notes.read_only"], routes: { list: "/notes" },
|
|
92
|
+
mount: [], privacy: { exports: true, deletes: true },
|
|
93
|
+
},
|
|
94
|
+
messages: { en, pl },
|
|
95
|
+
options: z.object({ limit: z.number().int().positive().default(100) }),
|
|
96
|
+
migrations: { dir: resolveMigrationsDir(import.meta.url, "../migrations/") },
|
|
97
|
+
privacy: { exportUserData, deleteUserData },
|
|
98
|
+
health: checkNotesReady, // optional: (context) => Promise<Result<undefined>>
|
|
99
|
+
// switchReader: optional, only for the app's switch provider (section 7)
|
|
100
|
+
// siteUrls: optional, only for the app's site URL provider (section 7)
|
|
101
|
+
});
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
- `resolveMigrationsDir(import.meta.url, "../migrations/")` gives the folder's `file:` URL. Do not
|
|
105
|
+
write `new URL("../migrations/", import.meta.url)` in a module: Next.js (Turbopack) treats that
|
|
106
|
+
literal form as an asset import and fails the app's `next build` on a folder, also from
|
|
107
|
+
`node_modules` (measured in identity ID-1).
|
|
108
|
+
- The manifest is checked against `moduleManifestSchema` when the package is imported; a
|
|
109
|
+
`dbSchema` needs `migrations`, and each privacy flag needs its function (and only then).
|
|
110
|
+
- The returned factory takes the module's options plus two reserved keys: `routes` (new paths
|
|
111
|
+
for known route names) and `messages` (partial copy per locale). It throws
|
|
112
|
+
`SoftureConfigError` naming the module and every invalid field.
|
|
113
|
+
- **`module.json`** is the JSON projection of the TS manifest, which is the source of truth at
|
|
114
|
+
runtime. A module's test keeps them equal:
|
|
115
|
+
`expect(JSON.parse(readFileSync("module.json", "utf8"))).toEqual(toModuleJson(notes))`.
|
|
116
|
+
- Version ranges in `dependsOn`: `x.y.z`, `^x.y.z`, `~x.y.z`, `*`, each optionally ending in `?`
|
|
117
|
+
(optional dependency). Caret follows npm on `0.x`: `^0.1.0` is `>=0.1.0 <0.2.0`.
|
|
118
|
+
|
|
119
|
+
## 4. Mounting
|
|
120
|
+
|
|
121
|
+
`@softure-ai/core/next` holds the config registry for server actions and route handlers shipped
|
|
122
|
+
in module packages, which cannot import the app's `softure.config.ts`:
|
|
123
|
+
|
|
124
|
+
- `registerSoftureConfig(config)`: call it in `softure.config.ts`, and import that file from
|
|
125
|
+
`instrumentation.ts` (it runs at server start) and from the root layout (`next build` prerenders
|
|
126
|
+
static pages without running instrumentation);
|
|
127
|
+
- `getSoftureConfig()`: read it inside package code; it throws when nothing was registered;
|
|
128
|
+
- `clearSoftureConfig()`: for tests.
|
|
129
|
+
|
|
130
|
+
Confirmed by identity ID-1 (`next-actions-spike`): a server action, a route handler and a server
|
|
131
|
+
component page shipped in a package all read the registered config, in `next dev` and in
|
|
132
|
+
`next build && next start`, installed as a packed copy or linked from the workspace. The rules for
|
|
133
|
+
package-shipped Next code are in docs/02 §8. `createSoftureHandlers` and `softureMiddleware` come
|
|
134
|
+
with the first module that mounts routes.
|
|
135
|
+
|
|
136
|
+
`@softure-ai/core/cli` is how a module's command-line tool (`softure migrate`, `softure-mail`,
|
|
137
|
+
`softure-blog`) finds the app's config, so the lookup and its messages live once:
|
|
138
|
+
|
|
139
|
+
- `takeConfigOption(argv)`: takes `--config <file>` or `--config=<file>` out of the arguments;
|
|
140
|
+
- `loadAppConfig({ cwd, configPath, appScript })`: imports the `--config` file, or the first of
|
|
141
|
+
`DEFAULT_CONFIG_FILES` (`softure.config.{ts,mts,js,mjs}`) in `cwd`; when Node cannot import it, the
|
|
142
|
+
problem names `appScript.runner` and the `appScript.packageName` README;
|
|
143
|
+
- `findDefaultConfig(cwd)` and `loadConfig(path, appScript, { database })`: the two halves on their own;
|
|
144
|
+
- `database: "optional"` (default `"required"`) on both loaders: for a command that never connects
|
|
145
|
+
(`softure-blog check`), the import runs inside `withDatabaseOptional`.
|
|
146
|
+
|
|
147
|
+
Each returns `{ ok: true, … }` or `{ ok: false, problem }`; the tool prefixes the problem with its name.
|
|
148
|
+
Node only: the root entry has no `node:` imports.
|
|
149
|
+
|
|
150
|
+
## 5. Migrations and tables
|
|
151
|
+
|
|
152
|
+
None. Core owns no database schema. A module points at its SQL folder with
|
|
153
|
+
`migrations: { dir: resolveMigrationsDir(import.meta.url, "../migrations/") }`; `@softure-ai/db`
|
|
154
|
+
(FD-4) applies the files.
|
|
155
|
+
|
|
156
|
+
## 6. Environment variables
|
|
157
|
+
|
|
158
|
+
None. Core reads no environment variables; the app passes values into `defineSoftureConfig`.
|
|
159
|
+
|
|
160
|
+
## 7. Switches
|
|
161
|
+
|
|
162
|
+
None of its own. Modules declare theirs in `manifest.switches`, each prefixed with the module id
|
|
163
|
+
(`notes.read_only`); the `feature-switches` module manages them.
|
|
164
|
+
|
|
165
|
+
Core holds the **switch-reader contract**, so a module can read a switch without importing the
|
|
166
|
+
module that stores them (feature-switches depends on auth, so auth could not import it back):
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
import { readSwitch } from "@softure-ai/core";
|
|
170
|
+
|
|
171
|
+
const reading = await readSwitch(ctx, "notes.read_only");
|
|
172
|
+
const isReadOnly = reading.kind === "value" ? reading.isEnabled : getNotesOptions(ctx.config).readOnly;
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
- `readSwitch(ctx, name)` asks the enabled module that passed `switchReader` to `defineModule`
|
|
176
|
+
and returns `SwitchReading`: `{ kind: "value", isEnabled }`, or `{ kind: "undeclared" }` when no
|
|
177
|
+
module provides a reader or the app did not define that switch there. On `undeclared` the module
|
|
178
|
+
uses its own default.
|
|
179
|
+
- A `SwitchReader` is `(context, name) => Promise<SwitchReading>`. It resolves read failures itself
|
|
180
|
+
(a fail mode) and never throws for an unknown name. `@softure-ai/feature-switches` is the provider.
|
|
181
|
+
- `defineSoftureConfig` refuses two enabled modules that provide a reader; `findSwitchReader(config)`
|
|
182
|
+
returns the one there is, or `null`.
|
|
183
|
+
- `readSwitch` caches nothing: each call is one read by the provider. Ask once per request.
|
|
184
|
+
|
|
185
|
+
Core also holds the **site-URL contract**, so a module can declare the absolute URLs of its pages
|
|
186
|
+
(canonical, Open Graph, JSON-LD, feeds) by the app's canonical rule without importing the module that
|
|
187
|
+
owns it (`@softure-ai/seo` is optional for the blog, and a bundler resolves every `import()`):
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
import { getSiteUrls } from "@softure-ai/core";
|
|
191
|
+
|
|
192
|
+
const urls = getSiteUrls(config);
|
|
193
|
+
urls.getCanonicalUrl("/notes/first"); // a page: the provider's host and trailing-slash rule
|
|
194
|
+
`${urls.origin}/notes/feed.xml`; // a file: on the site's origin as it is
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
- `getSiteUrls(config)` asks the enabled module that passed `siteUrls` to `defineModule` (a
|
|
198
|
+
`SiteUrlProvider`, `(config) => SiteUrls`); `@softure-ai/seo` is the provider. Without one, `origin`
|
|
199
|
+
is `appOrigin` and `getCanonicalUrl(path)` appends the path to it unchanged.
|
|
200
|
+
- `getCanonicalUrl` takes a path starting with a single `/` and throws for anything else.
|
|
201
|
+
- `defineSoftureConfig` refuses two enabled modules that provide site URLs; `findSiteUrlProvider(config)`
|
|
202
|
+
returns the one there is, or `null`.
|
|
203
|
+
|
|
204
|
+
## 8. Appearance
|
|
205
|
+
|
|
206
|
+
None. Tokens, slots and styles live in `@softure-ai/ui`.
|
|
207
|
+
|
|
208
|
+
## 9. Copy
|
|
209
|
+
|
|
210
|
+
- `LOCALES` is `["en", "pl"]`; every module ships both dictionaries complete (`pl.ts` is typed
|
|
211
|
+
`typeof en`).
|
|
212
|
+
- `mergeMessages(defaults, overrides)` applies partial overrides per locale; unknown keys and a
|
|
213
|
+
string in place of a group are ignored.
|
|
214
|
+
- `formatMessage("{count} of {total}", { count, total })` fills placeholders; a missing value
|
|
215
|
+
stays visible as `{name}`.
|
|
216
|
+
- `selectPlural(locale, count, { one, few, many, other })` uses `Intl.PluralRules`, built once per
|
|
217
|
+
locale and reused.
|
|
218
|
+
- `getMessage(dictionary, "errors.unexpected")` reads a dotted path.
|
|
219
|
+
- Core's own keys (`coreMessages`): `errors.database_failed`, `errors.unexpected`, the copy for
|
|
220
|
+
the `core.database_failed` and `core.unexpected` codes.
|
|
221
|
+
|
|
222
|
+
**Errors as values.** `Result<T, E>` is `{ ok: true, value } | { ok: false, error }`, built with
|
|
223
|
+
`ok(value)` / `ok()` and `err("module.code")`. Codes are namespaced by module id
|
|
224
|
+
(`` `${string}.${string}` ``); the UI translates them through messages.
|
|
225
|
+
`safeError(error)` turns a caught error into `core.database_failed` (a Drizzle `Failed query:` or a
|
|
226
|
+
bare SQL statement) or `core.unexpected`, so no SQL or parameter reaches a caller.
|
|
227
|
+
`errorLogLabel(error)` gives a log line with the error class and SQLSTATE only, never the text.
|
|
228
|
+
|
|
229
|
+
A message written for the user on purpose ("This plan has ended; pick another one.") travels as a
|
|
230
|
+
`PublicError`: `getPublicMessage(error)` returns its message, and `null` for every other error, so the
|
|
231
|
+
pass-through is one line and nothing else slips out:
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
import { getPublicMessage, PublicError, safeError } from "@softure-ai/core";
|
|
235
|
+
|
|
236
|
+
// domain code
|
|
237
|
+
if (plan.endedAt !== null) throw new PublicError("This plan has ended; pick another one.");
|
|
238
|
+
|
|
239
|
+
// where the error is caught (an MCP tool, an action)
|
|
240
|
+
} catch (error) {
|
|
241
|
+
return { text: getPublicMessage(error) ?? t(safeError(error).error) };
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
`isPublicError` recognises one from another copy of core too (a registry symbol, not `instanceof`).
|
|
246
|
+
`safeError` itself still answers `core.unexpected` for it, since callers switch on its two codes.
|
|
247
|
+
|
|
248
|
+
**Time.** Server code receives a `Clock` (`ModuleContext.clock`) instead of calling `new Date()`.
|
|
249
|
+
`systemClock` is the real one; `createTestClock(start)` has `advance(ms)` and `set(date)`.
|
|
250
|
+
A whole test run can also move to another day without code changes: `TEST_TODAY=YYYY-MM-DD` with the
|
|
251
|
+
`@softure-ai/testing/vitest-setup` setup file shifts the global `Date` (and so `systemClock`) to that
|
|
252
|
+
day while time keeps running ([`@softure-ai/testing`](../testing/README.md)).
|
|
253
|
+
|
|
254
|
+
## 10. Hooks
|
|
255
|
+
|
|
256
|
+
`ModuleContext` is what every server function of a module receives: `{ db, clock, config }`
|
|
257
|
+
(`db` is typed by `@softure-ai/db`). Request scope (cookies, headers) stays in `next/`.
|
|
258
|
+
|
|
259
|
+
`health` (optional) is the module's readiness probe, a `HealthCheck`:
|
|
260
|
+
`(context: ModuleContext) => Promise<Result<undefined>>`. `GET /api/health` of `@softure-ai/ops`
|
|
261
|
+
runs it for every enabled module that has one; `ok()` is healthy, an `Err` or a throw is not.
|
|
262
|
+
Keep it to one cheap query.
|
|
263
|
+
|
|
264
|
+
## 11. GDPR
|
|
265
|
+
|
|
266
|
+
Core collects nothing. It defines the contributor contract a module implements:
|
|
267
|
+
`exportUserData(context, userId)` returning `Result<unknown>` and
|
|
268
|
+
`deleteUserData(context, userId)` returning `Result<undefined>`, present exactly when the
|
|
269
|
+
manifest's `privacy.exports` / `privacy.deletes` flag is true. The `privacy` module runs them.
|
|
270
|
+
|
|
271
|
+
## 12. Limitations
|
|
272
|
+
|
|
273
|
+
- The registry in `next/` is provisional until ID-1.
|
|
274
|
+
- Versions are plain `x.y.z`; pre-release versions and range forms beyond `^`, `~`, exact and `*`
|
|
275
|
+
are rejected.
|
|
276
|
+
- Route maps mix mounted paths and redirect targets, so two modules may share a path; mount
|
|
277
|
+
collisions are left to `softure doctor`.
|
|
278
|
+
- No date or number formatting helpers yet; apps use `Intl` with `config.locale` and `config.timezone`.
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export { DEFAULT_CONFIG_FILES, findDefaultConfig, loadAppConfig, loadConfig, takeConfigOption, type AppScriptHint, type ConfigLoadResult, type ConfigOptionResult, type DatabaseRequirement, type LoadAppConfigOptions, type LoadConfigOptions, } from "./load-config.js";
|
|
2
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/cli/index.ts"],"names":[],"mappings":"AAEA,OAAO,EACL,oBAAoB,EACpB,iBAAiB,EACjB,aAAa,EACb,UAAU,EACV,gBAAgB,EAChB,KAAK,aAAa,EAClB,KAAK,gBAAgB,EACrB,KAAK,kBAAkB,EACvB,KAAK,mBAAmB,EACxB,KAAK,oBAAoB,EACzB,KAAK,iBAAiB,GACvB,MAAM,kBAAkB,CAAC"}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// `@softure-ai/core/cli`: the config loading shared by the modules' command-line tools. Node only;
|
|
2
|
+
// the root entry stays free of `node:` imports.
|
|
3
|
+
export { DEFAULT_CONFIG_FILES, findDefaultConfig, loadAppConfig, loadConfig, takeConfigOption, } from "./load-config.js";
|
|
4
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/cli/index.ts"],"names":[],"mappings":"AAAA,mGAAmG;AACnG,gDAAgD;AAChD,OAAO,EACL,oBAAoB,EACpB,iBAAiB,EACjB,aAAa,EACb,UAAU,EACV,gBAAgB,GAOjB,MAAM,kBAAkB,CAAC"}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { SoftureConfig } from "../config.js";
|
|
2
|
+
/** The file names looked up in the working directory when no `--config` is given, in this order. */
|
|
3
|
+
export declare const DEFAULT_CONFIG_FILES: readonly string[];
|
|
4
|
+
/** The function and package an app script calls when Node cannot import the config file. */
|
|
5
|
+
export interface AppScriptHint {
|
|
6
|
+
/** e.g. `runMigrateCli`. */
|
|
7
|
+
readonly runner: string;
|
|
8
|
+
/** e.g. `@softure-ai/db`, whose README shows the app script. */
|
|
9
|
+
readonly packageName: string;
|
|
10
|
+
}
|
|
11
|
+
export type ConfigOptionResult = {
|
|
12
|
+
readonly ok: true;
|
|
13
|
+
readonly configPath: string | undefined;
|
|
14
|
+
readonly argv: string[];
|
|
15
|
+
} | {
|
|
16
|
+
readonly ok: false;
|
|
17
|
+
readonly problem: string;
|
|
18
|
+
};
|
|
19
|
+
export type ConfigLoadResult = {
|
|
20
|
+
readonly ok: true;
|
|
21
|
+
readonly config: SoftureConfig;
|
|
22
|
+
} | {
|
|
23
|
+
readonly ok: false;
|
|
24
|
+
readonly problem: string;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Whether the command needs the config's database. `"optional"` is for a command that never connects
|
|
28
|
+
* (`softure-blog check`): a missing or empty URL then loads as `database: null` (see `withDatabaseOptional`).
|
|
29
|
+
*/
|
|
30
|
+
export type DatabaseRequirement = "required" | "optional";
|
|
31
|
+
export interface LoadConfigOptions {
|
|
32
|
+
/** Default `"required"`. */
|
|
33
|
+
readonly database?: DatabaseRequirement;
|
|
34
|
+
}
|
|
35
|
+
export interface LoadAppConfigOptions extends LoadConfigOptions {
|
|
36
|
+
readonly cwd: string;
|
|
37
|
+
/** The `--config` value, relative to `cwd`; `undefined` looks up `DEFAULT_CONFIG_FILES`. */
|
|
38
|
+
readonly configPath: string | undefined;
|
|
39
|
+
readonly appScript: AppScriptHint;
|
|
40
|
+
}
|
|
41
|
+
/** Takes `--config <file>` or `--config=<file>` out of the arguments and keeps the rest in order. */
|
|
42
|
+
export declare function takeConfigOption(argv: readonly string[]): ConfigOptionResult;
|
|
43
|
+
/** The first of `DEFAULT_CONFIG_FILES` that exists in `cwd`, as an absolute path. */
|
|
44
|
+
export declare function findDefaultConfig(cwd: string): string | undefined;
|
|
45
|
+
/** The config the file exports (default or `config`), or why it could not be used. */
|
|
46
|
+
export declare function loadConfig(path: string, appScript: AppScriptHint, options?: LoadConfigOptions): Promise<ConfigLoadResult>;
|
|
47
|
+
/** Loads the `--config` file, or the default one found in `cwd`. */
|
|
48
|
+
export declare function loadAppConfig(options: LoadAppConfigOptions): Promise<ConfigLoadResult>;
|
|
49
|
+
//# sourceMappingURL=load-config.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"load-config.d.ts","sourceRoot":"","sources":["../../src/cli/load-config.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAGlD,oGAAoG;AACpG,eAAO,MAAM,oBAAoB,EAAE,SAAS,MAAM,EAA2F,CAAC;AAE9I,4FAA4F;AAC5F,MAAM,WAAW,aAAa;IAC5B,4BAA4B;IAC5B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,gEAAgE;IAChE,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;CAC9B;AAED,MAAM,MAAM,kBAAkB,GAC1B;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,CAAA;CAAE,GACvF;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAErD,MAAM,MAAM,gBAAgB,GAAG;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAA;CAAE,GAAG;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAExI;;;GAGG;AACH,MAAM,MAAM,mBAAmB,GAAG,UAAU,GAAG,UAAU,CAAC;AAE1D,MAAM,WAAW,iBAAiB;IAChC,4BAA4B;IAC5B,QAAQ,CAAC,QAAQ,CAAC,EAAE,mBAAmB,CAAC;CACzC;AAED,MAAM,WAAW,oBAAqB,SAAQ,iBAAiB;IAC7D,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,4FAA4F;IAC5F,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,CAAC;IACxC,QAAQ,CAAC,SAAS,EAAE,aAAa,CAAC;CACnC;AAED,qGAAqG;AACrG,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,kBAAkB,CAiB5E;AAED,qFAAqF;AACrF,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAEjE;AAED,sFAAsF;AACtF,wBAAsB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,aAAa,EAAE,OAAO,GAAE,iBAAsB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAiBnI;AAED,oEAAoE;AACpE,wBAAsB,aAAa,CAAC,OAAO,EAAE,oBAAoB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAS5F"}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// How a module's command-line tool (`softure migrate`, `softure-mail`, `softure-blog`) finds and loads
|
|
2
|
+
// the app's `softure.config.*`. The file is loaded with a dynamic import, so it must be one Node can
|
|
3
|
+
// run itself: `.js`/`.mjs`, or `.ts` where Node strips types (Node >= 22.18; relative imports then
|
|
4
|
+
// need `.ts` extensions). Anything else uses an app script that passes the config to the command.
|
|
5
|
+
// Every problem is returned without the tool's name; each tool prefixes its own.
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
import { resolve } from "node:path";
|
|
8
|
+
import { pathToFileURL } from "node:url";
|
|
9
|
+
import { withDatabaseOptional } from "../database-requirement.js";
|
|
10
|
+
/** The file names looked up in the working directory when no `--config` is given, in this order. */
|
|
11
|
+
export const DEFAULT_CONFIG_FILES = ["softure.config.ts", "softure.config.mts", "softure.config.js", "softure.config.mjs"];
|
|
12
|
+
/** Takes `--config <file>` or `--config=<file>` out of the arguments and keeps the rest in order. */
|
|
13
|
+
export function takeConfigOption(argv) {
|
|
14
|
+
const rest = [];
|
|
15
|
+
let configPath;
|
|
16
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
17
|
+
const arg = argv[index] ?? "";
|
|
18
|
+
if (arg === "--config") {
|
|
19
|
+
const value = argv[index + 1];
|
|
20
|
+
if (value === undefined || value.startsWith("--"))
|
|
21
|
+
return { ok: false, problem: "--config needs a file path" };
|
|
22
|
+
configPath = value;
|
|
23
|
+
index += 1;
|
|
24
|
+
}
|
|
25
|
+
else if (arg.startsWith("--config=")) {
|
|
26
|
+
configPath = arg.slice("--config=".length);
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
rest.push(arg);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return { ok: true, configPath, argv: rest };
|
|
33
|
+
}
|
|
34
|
+
/** The first of `DEFAULT_CONFIG_FILES` that exists in `cwd`, as an absolute path. */
|
|
35
|
+
export function findDefaultConfig(cwd) {
|
|
36
|
+
return DEFAULT_CONFIG_FILES.map((name) => resolve(cwd, name)).find((path) => existsSync(path));
|
|
37
|
+
}
|
|
38
|
+
/** The config the file exports (default or `config`), or why it could not be used. */
|
|
39
|
+
export async function loadConfig(path, appScript, options = {}) {
|
|
40
|
+
if (!existsSync(path))
|
|
41
|
+
return { ok: false, problem: `config file ${path} does not exist` };
|
|
42
|
+
const importConfig = () => import(pathToFileURL(path).href);
|
|
43
|
+
let exported;
|
|
44
|
+
try {
|
|
45
|
+
exported = await (options.database === "optional" ? withDatabaseOptional(importConfig) : importConfig());
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
49
|
+
return {
|
|
50
|
+
ok: false,
|
|
51
|
+
problem: `cannot load ${path}: ${reason}. If Node cannot run this file, call ${appScript.runner} from an app script (see the ${appScript.packageName} README).`,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const config = exported.default ?? exported.config;
|
|
55
|
+
return isConfigLike(config)
|
|
56
|
+
? { ok: true, config }
|
|
57
|
+
: { ok: false, problem: `${path} must export (default or as "config") the result of defineSoftureConfig` };
|
|
58
|
+
}
|
|
59
|
+
/** Loads the `--config` file, or the default one found in `cwd`. */
|
|
60
|
+
export async function loadAppConfig(options) {
|
|
61
|
+
const path = options.configPath === undefined ? findDefaultConfig(options.cwd) : resolve(options.cwd, options.configPath);
|
|
62
|
+
if (path === undefined) {
|
|
63
|
+
return {
|
|
64
|
+
ok: false,
|
|
65
|
+
problem: `no config found; looked for ${DEFAULT_CONFIG_FILES.join(", ")} in ${options.cwd}; pass --config <file>`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return loadConfig(path, options.appScript, { database: options.database ?? "required" });
|
|
69
|
+
}
|
|
70
|
+
function isConfigLike(value) {
|
|
71
|
+
return typeof value === "object" && value !== null && "modules" in value && Array.isArray(value.modules) && "database" in value;
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=load-config.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"load-config.js","sourceRoot":"","sources":["../../src/cli/load-config.ts"],"names":[],"mappings":"AAAA,uGAAuG;AACvG,qGAAqG;AACrG,mGAAmG;AACnG,kGAAkG;AAClG,iFAAiF;AACjF,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,OAAO,EAAE,oBAAoB,EAAE,MAAM,4BAA4B,CAAC;AAElE,oGAAoG;AACpG,MAAM,CAAC,MAAM,oBAAoB,GAAsB,CAAC,mBAAmB,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,oBAAoB,CAAC,CAAC;AAkC9I,qGAAqG;AACrG,MAAM,UAAU,gBAAgB,CAAC,IAAuB;IACtD,MAAM,IAAI,GAAa,EAAE,CAAC;IAC1B,IAAI,UAA8B,CAAC;IACnC,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,IAAI,CAAC,MAAM,EAAE,KAAK,IAAI,CAAC,EAAE,CAAC;QACpD,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC;QAC9B,IAAI,GAAG,KAAK,UAAU,EAAE,CAAC;YACvB,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC;YAC9B,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,CAAC,UAAU,CAAC,IAAI,CAAC;gBAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,4BAA4B,EAAE,CAAC;YAC/G,UAAU,GAAG,KAAK,CAAC;YACnB,KAAK,IAAI,CAAC,CAAC;QACb,CAAC;aAAM,IAAI,GAAG,CAAC,UAAU,CAAC,WAAW,CAAC,EAAE,CAAC;YACvC,UAAU,GAAG,GAAG,CAAC,KAAK,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC;QAC7C,CAAC;aAAM,CAAC;YACN,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QACjB,CAAC;IACH,CAAC;IACD,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,UAAU,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;AAC9C,CAAC;AAED,qFAAqF;AACrF,MAAM,UAAU,iBAAiB,CAAC,GAAW;IAC3C,OAAO,oBAAoB,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,OAAO,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC;AACjG,CAAC;AAED,sFAAsF;AACtF,MAAM,CAAC,KAAK,UAAU,UAAU,CAAC,IAAY,EAAE,SAAwB,EAAE,UAA6B,EAAE;IACtG,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,eAAe,IAAI,iBAAiB,EAAE,CAAC;IAC3F,MAAM,YAAY,GAAG,GAAG,EAAE,CAAC,MAAM,CAAC,aAAa,CAAC,IAAI,CAAC,CAAC,IAAI,CAAqD,CAAC;IAChH,IAAI,QAAiD,CAAC;IACtD,IAAI,CAAC;QACH,QAAQ,GAAG,MAAM,CAAC,OAAO,CAAC,QAAQ,KAAK,UAAU,CAAC,CAAC,CAAC,oBAAoB,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC,YAAY,EAAE,CAAC,CAAC;IAC3G,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,MAAM,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;QACtE,OAAO;YACL,EAAE,EAAE,KAAK;YACT,OAAO,EAAE,eAAe,IAAI,KAAK,MAAM,wCAAwC,SAAS,CAAC,MAAM,gCAAgC,SAAS,CAAC,WAAW,WAAW;SAChK,CAAC;IACJ,CAAC;IACD,MAAM,MAAM,GAAG,QAAQ,CAAC,OAAO,IAAI,QAAQ,CAAC,MAAM,CAAC;IACnD,OAAO,YAAY,CAAC,MAAM,CAAC;QACzB,CAAC,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE;QACtB,CAAC,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,GAAG,IAAI,yEAAyE,EAAE,CAAC;AAC/G,CAAC;AAED,oEAAoE;AACpE,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,OAA6B;IAC/D,MAAM,IAAI,GAAG,OAAO,CAAC,UAAU,KAAK,SAAS,CAAC,CAAC,CAAC,iBAAiB,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC,GAAG,EAAE,OAAO,CAAC,UAAU,CAAC,CAAC;IAC1H,IAAI,IAAI,KAAK,SAAS,EAAE,CAAC;QACvB,OAAO;YACL,EAAE,EAAE,KAAK;YACT,OAAO,EAAE,+BAA+B,oBAAoB,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,OAAO,CAAC,GAAG,wBAAwB;SAClH,CAAC;IACJ,CAAC;IACD,OAAO,UAAU,CAAC,IAAI,EAAE,OAAO,CAAC,SAAS,EAAE,EAAE,QAAQ,EAAE,OAAO,CAAC,QAAQ,IAAI,UAAU,EAAE,CAAC,CAAC;AAC3F,CAAC;AAED,SAAS,YAAY,CAAC,KAAc;IAClC,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI,IAAI,SAAS,IAAI,KAAK,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,UAAU,IAAI,KAAK,CAAC;AAClI,CAAC"}
|
package/dist/clock.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface Clock {
|
|
2
|
+
/** The current instant. Every call returns a new `Date`, so callers may mutate it. */
|
|
3
|
+
now(): Date;
|
|
4
|
+
}
|
|
5
|
+
/** A clock a test moves by hand. */
|
|
6
|
+
export interface TestClock extends Clock {
|
|
7
|
+
/** Moves the clock forward (or back, with a negative value) by `milliseconds`. */
|
|
8
|
+
advance(milliseconds: number): void;
|
|
9
|
+
/** Moves the clock to `instant`. */
|
|
10
|
+
set(instant: Date): void;
|
|
11
|
+
}
|
|
12
|
+
/** The real wall clock. */
|
|
13
|
+
export declare const systemClock: Clock;
|
|
14
|
+
export declare function createTestClock(start: Date): TestClock;
|
|
15
|
+
//# sourceMappingURL=clock.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"clock.d.ts","sourceRoot":"","sources":["../src/clock.ts"],"names":[],"mappings":"AAGA,MAAM,WAAW,KAAK;IACpB,sFAAsF;IACtF,GAAG,IAAI,IAAI,CAAC;CACb;AAED,oCAAoC;AACpC,MAAM,WAAW,SAAU,SAAQ,KAAK;IACtC,kFAAkF;IAClF,OAAO,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IACpC,oCAAoC;IACpC,GAAG,CAAC,OAAO,EAAE,IAAI,GAAG,IAAI,CAAC;CAC1B;AAED,2BAA2B;AAC3B,eAAO,MAAM,WAAW,EAAE,KAEzB,CAAC;AAEF,wBAAgB,eAAe,CAAC,KAAK,EAAE,IAAI,GAAG,SAAS,CAYtD"}
|
package/dist/clock.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Time is injected, never read from the global `Date` inside module logic, so tests control it
|
|
2
|
+
// (docs/02-module-standard.md §9, FIRE_TRACKER's `now` convention).
|
|
3
|
+
/** The real wall clock. */
|
|
4
|
+
export const systemClock = {
|
|
5
|
+
now: () => new Date(),
|
|
6
|
+
};
|
|
7
|
+
export function createTestClock(start) {
|
|
8
|
+
let current = readTime(start);
|
|
9
|
+
return {
|
|
10
|
+
now: () => new Date(current),
|
|
11
|
+
advance: (milliseconds) => {
|
|
12
|
+
current += milliseconds;
|
|
13
|
+
},
|
|
14
|
+
set: (instant) => {
|
|
15
|
+
current = readTime(instant);
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function readTime(instant) {
|
|
20
|
+
const time = instant.getTime();
|
|
21
|
+
if (Number.isNaN(time)) {
|
|
22
|
+
throw new RangeError("createTestClock: invalid start date");
|
|
23
|
+
}
|
|
24
|
+
return time;
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=clock.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"clock.js","sourceRoot":"","sources":["../src/clock.ts"],"names":[],"mappings":"AAAA,+FAA+F;AAC/F,oEAAoE;AAepE,2BAA2B;AAC3B,MAAM,CAAC,MAAM,WAAW,GAAU;IAChC,GAAG,EAAE,GAAG,EAAE,CAAC,IAAI,IAAI,EAAE;CACtB,CAAC;AAEF,MAAM,UAAU,eAAe,CAAC,KAAW;IACzC,IAAI,OAAO,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC;IAE9B,OAAO;QACL,GAAG,EAAE,GAAG,EAAE,CAAC,IAAI,IAAI,CAAC,OAAO,CAAC;QAC5B,OAAO,EAAE,CAAC,YAAY,EAAE,EAAE;YACxB,OAAO,IAAI,YAAY,CAAC;QAC1B,CAAC;QACD,GAAG,EAAE,CAAC,OAAO,EAAE,EAAE;YACf,OAAO,GAAG,QAAQ,CAAC,OAAO,CAAC,CAAC;QAC9B,CAAC;KACF,CAAC;AACJ,CAAC;AAED,SAAS,QAAQ,CAAC,OAAa;IAC7B,MAAM,IAAI,GAAG,OAAO,CAAC,OAAO,EAAE,CAAC;IAC/B,IAAI,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;QACvB,MAAM,IAAI,UAAU,CAAC,qCAAqC,CAAC,CAAC;IAC9D,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare class SoftureConfigError extends Error {
|
|
2
|
+
readonly name = "SoftureConfigError";
|
|
3
|
+
/** One line per problem: `<path>: <what is wrong>`. */
|
|
4
|
+
readonly issues: readonly string[];
|
|
5
|
+
constructor(subject: string, issues: readonly string[]);
|
|
6
|
+
}
|
|
7
|
+
/** Formats zod issues as `<path>: <message>` lines, with an optional path prefix. */
|
|
8
|
+
export declare function formatIssues(issues: readonly {
|
|
9
|
+
readonly path: readonly PropertyKey[];
|
|
10
|
+
readonly message: string;
|
|
11
|
+
}[], prefix?: string): string[];
|
|
12
|
+
//# sourceMappingURL=config-error.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config-error.d.ts","sourceRoot":"","sources":["../src/config-error.ts"],"names":[],"mappings":"AAIA,qBAAa,kBAAmB,SAAQ,KAAK;IAC3C,SAAkB,IAAI,wBAAwB;IAE9C,uDAAuD;IACvD,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;gBAEvB,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,MAAM,EAAE;CAIvD;AAED,qFAAqF;AACrF,wBAAgB,YAAY,CAC1B,MAAM,EAAE,SAAS;IAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,WAAW,EAAE,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CAAE,EAAE,EACtF,MAAM,CAAC,EAAE,MAAM,GACd,MAAM,EAAE,CAKV"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// A configuration that cannot work: an invalid module manifest, invalid module options or an
|
|
2
|
+
// invalid app config. It is thrown at startup (docs/02-module-standard.md §7), because the app
|
|
3
|
+
// cannot run with it; every issue is listed at once so one restart shows all of them.
|
|
4
|
+
export class SoftureConfigError extends Error {
|
|
5
|
+
name = "SoftureConfigError";
|
|
6
|
+
/** One line per problem: `<path>: <what is wrong>`. */
|
|
7
|
+
issues;
|
|
8
|
+
constructor(subject, issues) {
|
|
9
|
+
super(`Invalid SOFTURE configuration in ${subject}:\n${issues.map((issue) => `- ${issue}`).join("\n")}`);
|
|
10
|
+
this.issues = issues;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** Formats zod issues as `<path>: <message>` lines, with an optional path prefix. */
|
|
14
|
+
export function formatIssues(issues, prefix) {
|
|
15
|
+
return issues.map((issue) => {
|
|
16
|
+
const path = [prefix, ...issue.path.map(String)].filter((part) => part !== undefined && part !== "").join(".");
|
|
17
|
+
return path === "" ? issue.message : `${path}: ${issue.message}`;
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
//# sourceMappingURL=config-error.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config-error.js","sourceRoot":"","sources":["../src/config-error.ts"],"names":[],"mappings":"AAAA,6FAA6F;AAC7F,+FAA+F;AAC/F,sFAAsF;AAEtF,MAAM,OAAO,kBAAmB,SAAQ,KAAK;IACzB,IAAI,GAAG,oBAAoB,CAAC;IAE9C,uDAAuD;IAC9C,MAAM,CAAoB;IAEnC,YAAY,OAAe,EAAE,MAAyB;QACpD,KAAK,CAAC,oCAAoC,OAAO,MAAM,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,KAAK,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QACzG,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;IACvB,CAAC;CACF;AAED,qFAAqF;AACrF,MAAM,UAAU,YAAY,CAC1B,MAAsF,EACtF,MAAe;IAEf,OAAO,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE;QAC1B,MAAM,IAAI,GAAG,CAAC,MAAM,EAAE,GAAG,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,KAAK,SAAS,IAAI,IAAI,KAAK,EAAE,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QAC/G,OAAO,IAAI,KAAK,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,IAAI,KAAK,KAAK,CAAC,OAAO,EAAE,CAAC;IACnE,CAAC,CAAC,CAAC;AACL,CAAC"}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { type Locale } from "./i18n.js";
|
|
2
|
+
import type { AnySoftureModule } from "./module.js";
|
|
3
|
+
import { type Result } from "./result.js";
|
|
4
|
+
/**
|
|
5
|
+
* Filled by `@softure-ai/db` through declaration merging with `{ handle: DatabaseHandle }`, so `database.handle`
|
|
6
|
+
* is typed wherever db's types are loaded. Core cannot import db (db depends on core).
|
|
7
|
+
*/
|
|
8
|
+
export interface SoftureDatabaseHandleTypes {
|
|
9
|
+
}
|
|
10
|
+
/** The handle `database.handle` returns: db's `DatabaseHandle`, or `unknown` where db's types are not loaded. */
|
|
11
|
+
export type SoftureDatabaseHandle = SoftureDatabaseHandleTypes extends {
|
|
12
|
+
readonly handle: infer Handle;
|
|
13
|
+
} ? Handle : unknown;
|
|
14
|
+
type MaybePromise<T> = T | Promise<T>;
|
|
15
|
+
export interface SoftureDatabaseConfig {
|
|
16
|
+
readonly url: string;
|
|
17
|
+
/**
|
|
18
|
+
* The app's own database handle, for an app that already has a client: every module, the health route and
|
|
19
|
+
* every package command then query through it instead of opening a second handle on `url` (with `pglite://`,
|
|
20
|
+
* a second PGlite instance on one directory corrupts it). Called on first use, not when the config is defined,
|
|
21
|
+
* so a build never connects. It must return the app's process-wide handle (memoized, e.g. on `globalThis`):
|
|
22
|
+
* `next dev` re-evaluates the config and calls a new function after each reload. Wrap a client with
|
|
23
|
+
* `createPostgresHandle(pool)` or `createPgliteHandle(client)` from `@softure-ai/db`.
|
|
24
|
+
*/
|
|
25
|
+
readonly handle?: () => MaybePromise<SoftureDatabaseHandle>;
|
|
26
|
+
}
|
|
27
|
+
export interface SoftureConfig {
|
|
28
|
+
/**
|
|
29
|
+
* `null` when the app has no database; required as soon as a module has a `dbSchema`. An empty `url`
|
|
30
|
+
* (`DATABASE_URL` unset, as in a build step) is accepted here and refused by the first connection.
|
|
31
|
+
*/
|
|
32
|
+
readonly database: SoftureDatabaseConfig | null;
|
|
33
|
+
readonly locale: Locale;
|
|
34
|
+
/** IANA time zone used for every date shown or computed per calendar day. */
|
|
35
|
+
readonly timezone: string;
|
|
36
|
+
/** Scheme, host and port of the app, without a path: `https://app.example.com`. */
|
|
37
|
+
readonly appOrigin: string;
|
|
38
|
+
/** Enabled modules, in the order the app listed them. */
|
|
39
|
+
readonly modules: readonly AnySoftureModule[];
|
|
40
|
+
}
|
|
41
|
+
/** What the app writes in `softure.config.ts`. */
|
|
42
|
+
export interface SoftureConfigInput {
|
|
43
|
+
readonly database?: SoftureDatabaseConfig | null;
|
|
44
|
+
readonly locale: Locale;
|
|
45
|
+
readonly timezone: string;
|
|
46
|
+
readonly appOrigin: string;
|
|
47
|
+
readonly modules: readonly AnySoftureModule[];
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Validates the app configuration and returns it frozen. Throws `SoftureConfigError` listing
|
|
51
|
+
* every problem: an app with an invalid configuration must not start.
|
|
52
|
+
*/
|
|
53
|
+
export declare function defineSoftureConfig(input: SoftureConfigInput): SoftureConfig;
|
|
54
|
+
/** The enabled module with this id, or `undefined` when the app did not list it. */
|
|
55
|
+
export declare function getModule(config: SoftureConfig, id: string): AnySoftureModule | undefined;
|
|
56
|
+
/**
|
|
57
|
+
* The modules with every listed dependency before its dependents (the migration order); ties
|
|
58
|
+
* keep the listed order. Dependencies that are not listed are ignored here.
|
|
59
|
+
*/
|
|
60
|
+
export declare function sortModulesByDependencies(modules: readonly AnySoftureModule[]): Result<AnySoftureModule[], "core.dependency_cycle">;
|
|
61
|
+
export {};
|
|
62
|
+
//# sourceMappingURL=config.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAKA,OAAO,EAAW,KAAK,MAAM,EAAE,MAAM,WAAW,CAAC;AACjD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AACpD,OAAO,EAAW,KAAK,MAAM,EAAE,MAAM,aAAa,CAAC;AAGnD;;;GAGG;AAEH,MAAM,WAAW,0BAA0B;CAAG;AAE9C,iHAAiH;AACjH,MAAM,MAAM,qBAAqB,GAAG,0BAA0B,SAAS;IAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,MAAM,CAAA;CAAE,GAAG,MAAM,GAAG,OAAO,CAAC;AAE5H,KAAK,YAAY,CAAC,CAAC,IAAI,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;AAEtC,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB;;;;;;;OAOG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,YAAY,CAAC,qBAAqB,CAAC,CAAC;CAC7D;AAED,MAAM,WAAW,aAAa;IAC5B;;;OAGG;IACH,QAAQ,CAAC,QAAQ,EAAE,qBAAqB,GAAG,IAAI,CAAC;IAChD,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,6EAA6E;IAC7E,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,mFAAmF;IACnF,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,yDAAyD;IACzD,QAAQ,CAAC,OAAO,EAAE,SAAS,gBAAgB,EAAE,CAAC;CAC/C;AAED,kDAAkD;AAClD,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,QAAQ,CAAC,EAAE,qBAAqB,GAAG,IAAI,CAAC;IACjD,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,OAAO,EAAE,SAAS,gBAAgB,EAAE,CAAC;CAC/C;AAsBD;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,kBAAkB,GAAG,aAAa,CAkB5E;AAED,oFAAoF;AACpF,wBAAgB,SAAS,CAAC,MAAM,EAAE,aAAa,EAAE,EAAE,EAAE,MAAM,GAAG,gBAAgB,GAAG,SAAS,CAEzF;AAED;;;GAGG;AACH,wBAAgB,yBAAyB,CACvC,OAAO,EAAE,SAAS,gBAAgB,EAAE,GACnC,MAAM,CAAC,gBAAgB,EAAE,EAAE,uBAAuB,CAAC,CAGrD"}
|