space-data-module-sdk 0.8.12 → 0.8.14

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.
@@ -15,7 +15,16 @@ import {
15
15
  import { FlatBufferTypeRefT } from "../generated/orbpro/stream/flat-buffer-type-ref.js";
16
16
  import { ProtocolRole, ProtocolTransportKind } from "../runtime/constants.js";
17
17
 
18
- const pluginFamilyByName = Object.freeze({
18
+ /**
19
+ * The manifest-string → PluginFamily vocabulary. This is the WHOLE vocabulary:
20
+ * anything not in here is refused by name, never coerced.
21
+ *
22
+ * `datasource` is the ONE alias, kept because manifests in the field spell it
23
+ * both ways. Do not add aliases casually — an alias is a second spelling of a
24
+ * family, and every one of them is a place two modules can disagree about what
25
+ * they are.
26
+ */
27
+ export const pluginFamilyByName = Object.freeze({
19
28
  sensor: PluginFamily.SENSOR,
20
29
  propagator: PluginFamily.PROPAGATOR,
21
30
  renderer: PluginFamily.RENDERER,
@@ -28,8 +37,89 @@ const pluginFamilyByName = Object.freeze({
28
37
  infrastructure: PluginFamily.INFRASTRUCTURE,
29
38
  flow: PluginFamily.FLOW,
30
39
  bridge: PluginFamily.BRIDGE,
40
+ maneuver: PluginFamily.MANEUVER,
41
+ orbit_determination: PluginFamily.ORBIT_DETERMINATION,
42
+ foundation: PluginFamily.FOUNDATION,
43
+ parser: PluginFamily.PARSER,
44
+ validator: PluginFamily.VALIDATOR,
45
+ exporter: PluginFamily.EXPORTER,
46
+ publisher: PluginFamily.PUBLISHER,
47
+ basilisk: PluginFamily.BASILISK,
31
48
  });
32
49
 
50
+ /** Every accepted family string, sorted — the vocabulary a refusal names. */
51
+ export const PluginFamilyNames = Object.freeze(
52
+ Object.keys(pluginFamilyByName).sort(),
53
+ );
54
+
55
+ /**
56
+ * SDK family → authoritative SDS `pluginCategory` member name.
57
+ *
58
+ * SDS (spacedatastandards.org `schema/PLG/main.fbs`) is the source of truth for
59
+ * what families exist; this SDK enum is a projection of it. The two DO NOT
60
+ * share ordinals — they diverge from index 5 (SDK `COMMS = 5`, SDS `EW = 5`) —
61
+ * so this mapping is BY NAME and must stay explicit. Never convert one enum to
62
+ * the other numerically.
63
+ *
64
+ * Two entries have no 1:1 SDS member and are recorded honestly rather than
65
+ * hidden. Both are filed in graph/tasks/sds-plugin-category-projection-gaps.md:
66
+ * - ORBIT_DETERMINATION → Analysis (SDS has no OD category yet)
67
+ * - SDF, BRIDGE → Analysis / Infrastructure (SDK-local concepts)
68
+ */
69
+ export const sdsPluginCategoryByFamily = Object.freeze({
70
+ [PluginFamily.SENSOR]: "Sensor",
71
+ [PluginFamily.PROPAGATOR]: "Propagator",
72
+ [PluginFamily.RENDERER]: "Renderer",
73
+ [PluginFamily.ANALYSIS]: "Analysis",
74
+ [PluginFamily.DATA_SOURCE]: "DataSource",
75
+ [PluginFamily.COMMS]: "Comms",
76
+ [PluginFamily.SHADER]: "Shader",
77
+ [PluginFamily.SDF]: "Analysis",
78
+ [PluginFamily.INFRASTRUCTURE]: "Infrastructure",
79
+ [PluginFamily.FLOW]: "Flow",
80
+ [PluginFamily.BRIDGE]: "Infrastructure",
81
+ [PluginFamily.MANEUVER]: "Maneuver",
82
+ [PluginFamily.ORBIT_DETERMINATION]: "Analysis",
83
+ [PluginFamily.FOUNDATION]: "Foundation",
84
+ [PluginFamily.PARSER]: "Parser",
85
+ [PluginFamily.VALIDATOR]: "Validator",
86
+ [PluginFamily.EXPORTER]: "Exporter",
87
+ [PluginFamily.PUBLISHER]: "Publisher",
88
+ [PluginFamily.BASILISK]: "Basilisk",
89
+ });
90
+
91
+ const PluginFamilyValues = Object.freeze(
92
+ new Set(Object.values(pluginFamilyByName)),
93
+ );
94
+
95
+ /**
96
+ * Thrown when a manifest declares a family the SDK does not know.
97
+ *
98
+ * It is a THROW and not a fallback on purpose. The old code returned
99
+ * `PluginFamily.ANALYSIS` for anything unrecognized, which is why 19
100
+ * first-party modules shipped mislabelled and family-typed resolution only
101
+ * ever worked for propagators: a typo and a deliberate new family were
102
+ * indistinguishable, and both were silent.
103
+ *
104
+ * Ruling: graph/findings/official-harness-shapes.md §4.7 / §8.3
105
+ */
106
+ export class UnknownPluginFamilyError extends Error {
107
+ constructor(value) {
108
+ super(
109
+ `Unknown pluginFamily ${JSON.stringify(value)}. ` +
110
+ `The manifest vocabulary is: ${PluginFamilyNames.join(", ")}. ` +
111
+ `Families are NOT invented in a manifest — the SDK enum ` +
112
+ `(schemas/PluginManifest.fbs PluginFamily) is a projection of the ` +
113
+ `authoritative SDS pluginCategory vocabulary, and a new family is ` +
114
+ `appended there first.`,
115
+ );
116
+ this.name = "UnknownPluginFamilyError";
117
+ this.code = "unknown-plugin-family";
118
+ this.value = value;
119
+ this.validFamilies = PluginFamilyNames;
120
+ }
121
+ }
122
+
33
123
  const drainPolicyByName = Object.freeze({
34
124
  "single-shot": ManifestDrainPolicy.SINGLE_SHOT,
35
125
  "drain-until-yield": ManifestDrainPolicy.DRAIN_UNTIL_YIELD,
@@ -145,14 +235,33 @@ function normalizeUnsignedInteger(value, fallback = 0) {
145
235
  return Math.max(0, Math.trunc(normalized));
146
236
  }
147
237
 
148
- function normalizePluginFamily(value) {
238
+ /**
239
+ * Resolve a manifest `pluginFamily` to its enum value, or REFUSE.
240
+ *
241
+ * There is no fallback. See {@link UnknownPluginFamilyError} for why.
242
+ *
243
+ * @param {string|number} value
244
+ * @returns {number} a PluginFamily member
245
+ * @throws {UnknownPluginFamilyError} on an unknown string, an out-of-range
246
+ * number, or a missing/blank value.
247
+ */
248
+ export function normalizePluginFamily(value) {
149
249
  if (typeof value === "number") {
250
+ // A numeric family still has to BE one. An out-of-range ordinal is the
251
+ // same defect as an unknown string, and used to sail straight through.
252
+ if (!PluginFamilyValues.has(value)) {
253
+ throw new UnknownPluginFamilyError(value);
254
+ }
150
255
  return value;
151
256
  }
152
- const normalized = String(value ?? "analysis")
257
+ const normalized = String(value ?? "")
153
258
  .trim()
154
259
  .toLowerCase();
155
- return pluginFamilyByName[normalized] ?? PluginFamily.ANALYSIS;
260
+ const resolved = pluginFamilyByName[normalized];
261
+ if (resolved === undefined) {
262
+ throw new UnknownPluginFamilyError(value);
263
+ }
264
+ return resolved;
156
265
  }
157
266
 
158
267
  function normalizeDrainPolicy(value) {
@@ -0,0 +1,71 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { substituteTokens } from "./tokens.js";
5
+
6
+ /**
7
+ * Refuse to scaffold into a non-empty directory unless `force` is set. Never
8
+ * deletes anything — `force` only lifts the refusal, it does not clear the
9
+ * directory first, so pre-existing unrelated files are left alone and
10
+ * template files land on top of (overwrite) any same-named files.
11
+ */
12
+ export async function ensureWritableOutputDir(outDir, force) {
13
+ let entries;
14
+ try {
15
+ entries = await fs.readdir(outDir);
16
+ } catch (error) {
17
+ if (error && error.code === "ENOENT") {
18
+ return;
19
+ }
20
+ throw error;
21
+ }
22
+ if (entries.length > 0 && !force) {
23
+ throw new Error(
24
+ `Refusing to scaffold into non-empty directory ${outDir} ` +
25
+ `(${entries.length} existing ${entries.length === 1 ? "entry" : "entries"}). ` +
26
+ `Pass --force to scaffold into it anyway.`,
27
+ );
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Copy every file under `templateDir` into `outDir`, applying token
33
+ * substitution to BOTH file contents and file/directory names. Every
34
+ * template file is treated as UTF-8 text — correct for this SDK's templates
35
+ * (JSON/JS/C/C++/Markdown), and deliberate: a template that ever needs a
36
+ * binary asset is a signal to reconsider, not something this copier should
37
+ * silently support.
38
+ *
39
+ * Returns the sorted list of output-relative (posix-style) file paths that
40
+ * were written.
41
+ */
42
+ export async function copyTemplateTree(templateDir, outDir, tokens) {
43
+ const created = [];
44
+
45
+ async function walk(currentTemplateDir, currentOutDir) {
46
+ const entries = await fs.readdir(currentTemplateDir, {
47
+ withFileTypes: true,
48
+ });
49
+ for (const entry of entries) {
50
+ const destName = substituteTokens(entry.name, tokens);
51
+ const srcPath = path.join(currentTemplateDir, entry.name);
52
+ const destPath = path.join(currentOutDir, destName);
53
+ if (entry.isDirectory()) {
54
+ await fs.mkdir(destPath, { recursive: true });
55
+ await walk(srcPath, destPath);
56
+ } else if (entry.isFile()) {
57
+ await fs.mkdir(path.dirname(destPath), { recursive: true });
58
+ const raw = await fs.readFile(srcPath, "utf8");
59
+ await fs.writeFile(destPath, substituteTokens(raw, tokens), "utf8");
60
+ created.push(
61
+ path.relative(outDir, destPath).split(path.sep).join("/"),
62
+ );
63
+ }
64
+ }
65
+ }
66
+
67
+ await fs.mkdir(outDir, { recursive: true });
68
+ await walk(templateDir, outDir);
69
+ created.sort();
70
+ return created;
71
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * `space-data-module init` — scaffold a new SDN WASM module skeleton from a
3
+ * family template under `templates/<family>-module/`.
4
+ *
5
+ * Family resolution deliberately mirrors `normalizePluginFamily`
6
+ * (`src/manifest/normalize.js`): an unrecognized value is a THROW, never a
7
+ * silent fallback to a generic template. Two checks apply, in order:
8
+ *
9
+ * 1. Is `--family` even a name in the SDK's plugin-family vocabulary
10
+ * (the same vocabulary `pluginFamily` in a manifest is validated
11
+ * against)? If not, `normalizePluginFamily` itself throws
12
+ * `UnknownPluginFamilyError`, naming the offender and the whole
13
+ * vocabulary.
14
+ * 2. Does that family have an init TEMPLATE (`templates/<family>-module/
15
+ * plugin-manifest.json`)? Most families do not — only `propagator`
16
+ * ships one today. If not, `ScaffoldFamilyTemplateError` names the
17
+ * offender and lists the families that DO have a template.
18
+ *
19
+ * Both refusals name the value and the valid vocabulary; neither ever
20
+ * degrades into "pick the closest template" or "use a blank template".
21
+ */
22
+ import fs from "node:fs";
23
+ import path from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+
26
+ import { normalizePluginFamily } from "../manifest/normalize.js";
27
+ import { copyTemplateTree, ensureWritableOutputDir } from "./copyTemplate.js";
28
+ import { buildTokenMap } from "./tokens.js";
29
+
30
+ const moduleDir = path.dirname(fileURLToPath(import.meta.url));
31
+ export const packageRoot = path.resolve(moduleDir, "..", "..");
32
+ export const templatesRoot = path.join(packageRoot, "templates");
33
+
34
+ const TEMPLATE_DIR_SUFFIX = "-module";
35
+ /** The file every real init-scaffold template must have at its root. This is
36
+ * what distinguishes a scaffold template (propagator-module) from the
37
+ * pre-existing ABI-header-only reference dirs under templates/
38
+ * (gpu-module, provider-access-module) that ship no plugin-manifest.json and
39
+ * are not meant to be scaffolded from. */
40
+ const TEMPLATE_MANIFEST_FILE = "plugin-manifest.json";
41
+
42
+ /**
43
+ * Thrown when `--family` names a value that does not have an init template,
44
+ * even if it IS a recognized `pluginFamily` string. Distinct from
45
+ * `UnknownPluginFamilyError` (which fires first, for values that are not a
46
+ * recognized family AT ALL) — this one fires for real-but-template-less
47
+ * families such as "sensor" today.
48
+ */
49
+ export class ScaffoldFamilyTemplateError extends Error {
50
+ constructor(value, availableFamilies) {
51
+ const list =
52
+ availableFamilies.length > 0
53
+ ? availableFamilies.join(", ")
54
+ : "(none — no templates/*-module directory has a plugin-manifest.json yet)";
55
+ super(
56
+ `No init template for family ${JSON.stringify(value)}. ` +
57
+ `Families with an init template: ${list}.`,
58
+ );
59
+ this.name = "ScaffoldFamilyTemplateError";
60
+ this.code = "unknown-scaffold-family-template";
61
+ this.value = value;
62
+ this.availableFamilies = availableFamilies;
63
+ }
64
+ }
65
+
66
+ /** List family names (e.g. `["propagator"]`) that have a real init template. */
67
+ export function listScaffoldFamilies() {
68
+ let entries;
69
+ try {
70
+ entries = fs.readdirSync(templatesRoot, { withFileTypes: true });
71
+ } catch (error) {
72
+ if (error && error.code === "ENOENT") {
73
+ return [];
74
+ }
75
+ throw error;
76
+ }
77
+ const families = [];
78
+ for (const entry of entries) {
79
+ if (!entry.isDirectory() || !entry.name.endsWith(TEMPLATE_DIR_SUFFIX)) {
80
+ continue;
81
+ }
82
+ const manifestPath = path.join(
83
+ templatesRoot,
84
+ entry.name,
85
+ TEMPLATE_MANIFEST_FILE,
86
+ );
87
+ if (fs.existsSync(manifestPath)) {
88
+ families.push(entry.name.slice(0, -TEMPLATE_DIR_SUFFIX.length));
89
+ }
90
+ }
91
+ return families.sort();
92
+ }
93
+
94
+ /**
95
+ * Resolve `--family` to a template directory, or throw loudly. See the
96
+ * module docstring for the two-stage refusal shape.
97
+ */
98
+ export function resolveScaffoldTemplateDir(family) {
99
+ // Stage 1: is this even a real plugin family? Throws UnknownPluginFamilyError
100
+ // (naming the value + the FULL manifest vocabulary) if not.
101
+ normalizePluginFamily(family);
102
+
103
+ const normalized = String(family).trim().toLowerCase();
104
+ const available = listScaffoldFamilies();
105
+ if (!available.includes(normalized)) {
106
+ // Stage 2: real family, but no init template ships for it (yet).
107
+ throw new ScaffoldFamilyTemplateError(family, available);
108
+ }
109
+ return path.join(templatesRoot, `${normalized}${TEMPLATE_DIR_SUFFIX}`);
110
+ }
111
+
112
+ /**
113
+ * Scaffold a new module skeleton.
114
+ *
115
+ * @param {object} options
116
+ * @param {string} options.family - e.g. "propagator". Must have an init
117
+ * template (see {@link resolveScaffoldTemplateDir}).
118
+ * @param {string} options.name - kebab-case module name, e.g. "my-propagator".
119
+ * @param {string} [options.outDir] - defaults to `./<name>` under cwd.
120
+ * @param {string} [options.pluginId] - defaults to
121
+ * `com.orbpro.<name-with-dots>`.
122
+ * @param {boolean} [options.force] - allow scaffolding into a non-empty
123
+ * `outDir`.
124
+ * @returns {Promise<{ok: true, family: string, name: string, pluginId: string, outDir: string, files: string[]}>}
125
+ */
126
+ export async function scaffoldModule({
127
+ family,
128
+ name,
129
+ outDir,
130
+ pluginId,
131
+ force = false,
132
+ } = {}) {
133
+ const templateDir = resolveScaffoldTemplateDir(family);
134
+ const tokens = buildTokenMap({ name, pluginId });
135
+ const resolvedOutDir = path.resolve(outDir ?? path.join(process.cwd(), name));
136
+
137
+ await ensureWritableOutputDir(resolvedOutDir, force === true);
138
+ const files = await copyTemplateTree(templateDir, resolvedOutDir, tokens);
139
+
140
+ return {
141
+ ok: true,
142
+ family: String(family).trim().toLowerCase(),
143
+ name,
144
+ pluginId: tokens.PLUGIN_ID,
145
+ outDir: resolvedOutDir,
146
+ files,
147
+ };
148
+ }
149
+
150
+ export { defaultPluginId } from "./tokens.js";
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Token vocabulary for `space-data-module init`.
3
+ *
4
+ * These are the ONLY tokens the scaffold engine substitutes. They are matched
5
+ * as whole `__NAME__` runs so a token can never partially match inside a
6
+ * longer identifier that happens to share a prefix.
7
+ */
8
+ export const SCAFFOLD_TOKEN_NAMES = Object.freeze([
9
+ "MODULE_NAME",
10
+ "PLUGIN_ID",
11
+ "MODULE_NAME_SNAKE",
12
+ "MODULE_NAME_CAMEL",
13
+ ]);
14
+
15
+ const TOKEN_PATTERN = new RegExp(
16
+ `__(${SCAFFOLD_TOKEN_NAMES.join("|")})__`,
17
+ "g",
18
+ );
19
+
20
+ const MODULE_NAME_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
21
+
22
+ /**
23
+ * Validate a `--name` value against the kebab-case shape every derived token
24
+ * assumes. Refuses loudly rather than silently mangling an unexpected name
25
+ * into something that merely looks plausible.
26
+ */
27
+ export function assertValidModuleName(name) {
28
+ if (typeof name !== "string" || name.trim().length === 0) {
29
+ throw new Error("--name is required and must be a non-empty string.");
30
+ }
31
+ if (!MODULE_NAME_PATTERN.test(name)) {
32
+ throw new Error(
33
+ `--name ${JSON.stringify(name)} is not valid. Module names must be ` +
34
+ `lowercase kebab-case: start with a letter, then letters, digits, or ` +
35
+ `single hyphens (e.g. "keplerian-reference").`,
36
+ );
37
+ }
38
+ }
39
+
40
+ /** `foo-bar` -> `foo_bar` */
41
+ export function toSnakeCase(name) {
42
+ return name.replace(/-/g, "_");
43
+ }
44
+
45
+ /** `foo-bar` -> `fooBar` */
46
+ export function toCamelCase(name) {
47
+ return name
48
+ .split(/[-_]+/)
49
+ .filter(Boolean)
50
+ .map((part, index) =>
51
+ index === 0
52
+ ? part.toLowerCase()
53
+ : part.charAt(0).toUpperCase() + part.slice(1).toLowerCase(),
54
+ )
55
+ .join("");
56
+ }
57
+
58
+ /**
59
+ * `com.orbpro.<module-name-with-dots>` — the documented default `--plugin-id`
60
+ * when the author does not supply one. Hyphens become dots so the plugin id
61
+ * reads as reverse-DNS-style segments, matching how the SDK's own examples
62
+ * namespace multi-word module ids.
63
+ */
64
+ export function defaultPluginId(name) {
65
+ return `com.orbpro.${name.replace(/-/g, ".")}`;
66
+ }
67
+
68
+ /**
69
+ * Build the full token -> replacement-string map for one scaffold run.
70
+ */
71
+ export function buildTokenMap({ name, pluginId }) {
72
+ assertValidModuleName(name);
73
+ const resolvedPluginId =
74
+ typeof pluginId === "string" && pluginId.trim().length > 0
75
+ ? pluginId.trim()
76
+ : defaultPluginId(name);
77
+ return {
78
+ MODULE_NAME: name,
79
+ PLUGIN_ID: resolvedPluginId,
80
+ MODULE_NAME_SNAKE: toSnakeCase(name),
81
+ MODULE_NAME_CAMEL: toCamelCase(name),
82
+ };
83
+ }
84
+
85
+ /** Substitute every `__TOKEN__` occurrence in `text` using `tokens`. */
86
+ export function substituteTokens(text, tokens) {
87
+ return text.replace(TOKEN_PATTERN, (match, key) =>
88
+ key in tokens ? tokens[key] : match,
89
+ );
90
+ }
@@ -0,0 +1,99 @@
1
+ # __MODULE_NAME__
2
+
3
+ Scaffolded by `space-data-module init --family propagator --name __MODULE_NAME__`
4
+ from space-data-module-sdk's `templates/propagator-module/` template.
5
+
6
+ This is a **minimal but building** skeleton of an SDN propagator module: every
7
+ ABI obligation is already implemented — exports, wire layout, units, frames,
8
+ identity, threading discipline, error codes, lifetime — and the orbital
9
+ mechanics are a placeholder. It compiles and passes the SDK's own compliance
10
+ checks as-is; it just doesn't propagate anything real yet.
11
+
12
+ ## Files
13
+
14
+ - `plugin-manifest.json` — the module manifest. `pluginId` defaults to
15
+ `com.orbpro.<name-with-dots>`; `pluginFamily` is `propagator`; declares one
16
+ invoke method (`ingest_omm`) plus the propagator ABI exports below.
17
+ - `src/__MODULE_NAME_SNAKE__.cpp` — the module source. Search for
18
+ `TODO: your propagation goes here` — there are two spots (element adoption
19
+ in `adopt_omm()`, and the actual propagation in `propagate_entity()`).
20
+ Everything else in the file is ABI plumbing; you should not need to touch
21
+ export names, signatures, error codes, or the state-vector write pattern.
22
+ - `build.js` — compiles through `compileModuleFromSource` (the SDK compiler
23
+ lane). Inlines the generated `orbpro_propagator_abi.h` from your pinned
24
+ `space-data-module-sdk` dependency — never hand-copy that header.
25
+ - `tests/module.build.test.mjs` — manifest shape check (always runs) plus
26
+ compliance/export checks that skip until you've built the module.
27
+ - `package.json` — `"sdn-module"` points at the canonical isomorphic
28
+ artifact; `space-data-module-sdk` is a normal npm dependency.
29
+
30
+ ## Naming
31
+
32
+ This module was scaffolded with `--name __MODULE_NAME__`. `space-data-module
33
+ init` substituted four spellings of that name into this tree; if you need to
34
+ introduce your own file or identifier later, reuse the same shapes rather
35
+ than inventing a fifth:
36
+
37
+ | Spelling | This module's value | Used for |
38
+ | --------------------- | -------------------- | ------------------------------------------ |
39
+ | kebab-case | `__MODULE_NAME__` | display text, kebab-case filenames |
40
+ | reverse-DNS plugin id | `__PLUGIN_ID__` | `plugin-manifest.json`'s `pluginId` |
41
+ | snake_case | `__MODULE_NAME_SNAKE__` | C/C++ file and symbol names |
42
+ | camelCase | `__MODULE_NAME_CAMEL__` | a JS-safe identifier (e.g. a bindings key) |
43
+
44
+ ## Next steps
45
+
46
+ 1. `npm install` (pulls `space-data-module-sdk` and its `spacedatastandards.org`
47
+ dependency).
48
+ 2. Fill in the physics: replace the two `TODO: your propagation goes here`
49
+ blocks in `src/__MODULE_NAME_SNAKE__.cpp`. Keep every export, error code,
50
+ and the "zero the struct, set frame explicitly, set VALID last" write
51
+ pattern — those are the ABI contract, not style.
52
+ 3. `npm run build` — writes `dist/isomorphic/module.wasm` +
53
+ `dist/plugin-manifest.json`. The build fails loudly if the compiled
54
+ artifact does not pass the SDK's own manifest/artifact validation.
55
+ 4. `npm test` — the manifest-shape test always runs; the compliance and
56
+ export-surface tests turn on once step 3 has produced a wasm artifact.
57
+ 5. Replace the TODO test at the bottom of `tests/module.build.test.mjs` with
58
+ a real assertion once you have physics to check (ingest a known OMM,
59
+ propagate to a known epoch, compare against an independent reference —
60
+ e.g. another propagator or published ephemeris).
61
+ 6. Update `description` in `plugin-manifest.json` and `package.json` — both
62
+ still say "TODO" / a generic scaffold description.
63
+
64
+ ## The ABI, in one paragraph
65
+
66
+ A propagator module exports `plugin_init`, `plugin_init_omm`,
67
+ `plugin_ingest_omm_one`, `plugin_propagate`, `plugin_propagate_batch`,
68
+ `plugin_entity_count`, and `plugin_destroy` against the generated
69
+ `OrbProStateVector` / `OrbProOMMRecord` / `OrbProOrbitalElements` structs
70
+ (`orbpro/orbpro_propagator_abi.h` in your pinned `space-data-module-sdk`,
71
+ generated from `schemas/orbpro/Propagator.fbs` — never hand-retype these
72
+ structs, that is the exact drift this generated header exists to end).
73
+ Position/velocity output is always METERS / METERS-PER-SECOND with an
74
+ explicit `reference_frame`; identity is carried by `NORAD_CAT_ID`, never
75
+ derived from array position; every failure returns a named negative code;
76
+ `plugin_destroy` must actually free, not no-op.
77
+
78
+ **Threading.** This module declares `threadModel: "wasi-sequential"` — it
79
+ never spawns a thread of its own, which is the *strong default* for a
80
+ propagator: propagation is embarrassingly parallel across entities but
81
+ sequential within one, and the ABI puts the sharding decision on the HOST
82
+ (e.g. a frame-worker pool), not the module. `build.js` passes
83
+ `threadModel: manifest.threadModel` to the compiler EXPLICITLY — do not
84
+ remove that. `resolveThreadModel` reads the compile option, not
85
+ `manifest.threadModel`, and otherwise infers the model from
86
+ `runtimeTargets`, where `"wasmedge"` infers the OTHER model
87
+ (`emscripten-pthreads`, which in this SDK means the clang
88
+ `wasm32-wasip1-threads` / wasi-threads contract — never `emcc -pthread`,
89
+ which cannot thread under WasmEdge at all). Passing `threadModel` in
90
+ `build.js` sidesteps that inference and is what keeps this manifest's
91
+ declared model and the compiled artifact in agreement — `build.js` also
92
+ asserts they agree after compiling. See `docs/propagator-abi.md`
93
+ "Threading" if you ever need the other model.
94
+
95
+ The one SDN invoke method, `ingest_omm`, is separate from the propagator ABI
96
+ exports above: it is how a flow graph feeds this module SDS `$OMM` records
97
+ over the generic invoke surface, while `plugin_propagate` /
98
+ `plugin_propagate_batch` are called directly by a host that has already
99
+ linked this module as a propagator.
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Build __MODULE_NAME__ through the SDK compiler lane.
3
+ *
4
+ * `compileModuleFromSource` takes ONE translation unit, so the generated
5
+ * OrbPro propagator ABI header is INLINED into the source before compiling.
6
+ * That inlining is mechanical and one-directional: the header is read from
7
+ * the pinned `space-data-module-sdk` package, never copied into this repo.
8
+ * If the SDK's ABI changes, this build picks it up on the next `npm run
9
+ * build` — do not hand-vendor a copy of the header beside this file.
10
+ *
11
+ * Thread model: the manifest declares `threadModel: "wasi-sequential"` (see
12
+ * plugin-manifest.json's `sequentialJustification`) — this module never
13
+ * spawns a thread, which is the strong default for a propagator (sharding a
14
+ * batch belongs to the HOST, not the module; see docs/propagator-abi.md
15
+ * "Threading"). Both `wasi-sequential` and the threaded `emscripten-pthreads`
16
+ * model compile through the SAME clang `wasm32-wasip1-threads` toolchain —
17
+ * never `emcc -pthread`, which is browser-only and cannot thread under
18
+ * WasmEdge — they differ only in which link-time contract the SDK's
19
+ * post-link artifact guard then validates against the emitted wasm.
20
+ */
21
+
22
+ import fs from "node:fs/promises";
23
+ import { createRequire } from "node:module";
24
+ import path from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+
27
+ import { compileModuleFromSource } from "space-data-module-sdk/compiler";
28
+
29
+ const require = createRequire(import.meta.url);
30
+ const packageRoot = fileURLToPath(new URL(".", import.meta.url));
31
+ const manifestPath = path.join(packageRoot, "plugin-manifest.json");
32
+ const sourcePath = path.join(packageRoot, "src", "__MODULE_NAME_SNAKE__.cpp");
33
+ const distRoot = path.join(packageRoot, "dist");
34
+ const outputPath = path.join(distRoot, "isomorphic", "module.wasm");
35
+
36
+ const standardsRoot = path.dirname(
37
+ require.resolve("spacedatastandards.org/package.json"),
38
+ );
39
+ process.env.SPACE_DATA_STANDARDS_ROOT ??= `${standardsRoot}${path.sep}`;
40
+
41
+ /** The ONE source of the ABI, resolved from the pinned SDK package. */
42
+ const abiHeaderPath = require.resolve(
43
+ "space-data-module-sdk/include/orbpro/orbpro_propagator_abi.h",
44
+ );
45
+
46
+ const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8"));
47
+ const abiHeader = await fs.readFile(abiHeaderPath, "utf8");
48
+ const rawSource = await fs.readFile(sourcePath, "utf8");
49
+
50
+ const INCLUDE_LINE = '#include "orbpro/orbpro_propagator_abi.h"';
51
+ if (!rawSource.includes(INCLUDE_LINE)) {
52
+ throw new Error(
53
+ `${path.relative(packageRoot, sourcePath)} no longer includes the generated ABI header. ` +
54
+ `A propagator module must build against the ONE generated ABI, not a local copy.`,
55
+ );
56
+ }
57
+
58
+ const sourceCode = rawSource.replace(
59
+ INCLUDE_LINE,
60
+ [
61
+ `// --- BEGIN INLINED ${path.basename(abiHeaderPath)} (from ${manifest.pluginId}'s pinned SDK) ---`,
62
+ abiHeader,
63
+ `// --- END INLINED ${path.basename(abiHeaderPath)} ---`,
64
+ ].join("\n"),
65
+ );
66
+
67
+ await fs.rm(distRoot, { recursive: true, force: true });
68
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
69
+
70
+ const compilation = await compileModuleFromSource({
71
+ manifest,
72
+ sourceCode,
73
+ language: "c++",
74
+ outputPath,
75
+ // PASSED EXPLICITLY ON PURPOSE. `resolveThreadModel` reads the compile
76
+ // OPTION, not `manifest.threadModel`, and otherwise infers the model from
77
+ // `runtimeTargets` — where "wasmedge" infers pthreads. A manifest that
78
+ // declares `wasi-sequential` and does not pass it here would be silently
79
+ // compiled under the OTHER model and then rejected by the post-link
80
+ // artifact guard for not spawning a thread it never claimed to spawn.
81
+ // Filed as `sdk-manifest-threadmodel-silently-ignored`; keep this explicit
82
+ // until that lands.
83
+ threadModel: manifest.threadModel,
84
+ });
85
+
86
+ if (compilation.threadModel !== manifest.threadModel) {
87
+ throw new Error(
88
+ `threadModel drift: the manifest declares ${manifest.threadModel} but the ` +
89
+ `compiler resolved ${compilation.threadModel}.`,
90
+ );
91
+ }
92
+
93
+ await fs.copyFile(manifestPath, path.join(distRoot, "plugin-manifest.json"));
94
+
95
+ if (!compilation.report?.ok) {
96
+ const issues = JSON.stringify(compilation.report?.issues ?? [], null, 2);
97
+ throw new Error(`Compiled __MODULE_NAME__ artifact failed SDK validation:\n${issues}`);
98
+ }
99
+
100
+ console.log(
101
+ `Built ${path.relative(packageRoot, outputPath)} ` +
102
+ `(${compilation.compiler}, threadModel=${compilation.threadModel})`,
103
+ );
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "space-data-network-module-propagator-__MODULE_NAME__",
3
+ "version": "0.1.0",
4
+ "description": "__MODULE_NAME__ — an SDN propagator module scaffolded from the space-data-module-sdk propagator-module template.",
5
+ "type": "module",
6
+ "sdn-module": "./dist/isomorphic/module.wasm",
7
+ "exports": {
8
+ "./plugin-manifest.json": "./plugin-manifest.json",
9
+ "./dist/*": "./dist/*"
10
+ },
11
+ "scripts": {
12
+ "build": "node build.js",
13
+ "test": "node --test tests/*.test.mjs"
14
+ },
15
+ "dependencies": {
16
+ "space-data-module-sdk": "^0.8.14"
17
+ },
18
+ "private": true
19
+ }