@ultimat3/cli 27.3.0 → 27.5.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "27.3.0",
3
+ "version": "27.5.0",
4
4
  "description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -39,33 +39,33 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "@babel/core": "^8.0.1",
42
- "@ultimat3/action": "27.3.0",
43
- "@ultimat3/admin": "27.3.0",
44
- "@ultimat3/ai": "27.3.0",
45
- "@ultimat3/auth": "27.3.0",
46
- "@ultimat3/cache": "27.3.0",
47
- "@ultimat3/core": "27.3.0",
48
- "@ultimat3/db": "27.3.0",
49
- "@ultimat3/entity": "27.3.0",
50
- "@ultimat3/flags": "27.3.0",
51
- "@ultimat3/http": "27.3.0",
52
- "@ultimat3/i18n": "27.3.0",
53
- "@ultimat3/jobs": "27.3.0",
54
- "@ultimat3/mail": "27.3.0",
55
- "@ultimat3/manifest": "27.3.0",
56
- "@ultimat3/mcp": "27.3.0",
57
- "@ultimat3/money": "27.3.0",
58
- "@ultimat3/notify": "27.3.0",
59
- "@ultimat3/policy": "27.3.0",
60
- "@ultimat3/pwa": "27.3.0",
61
- "@ultimat3/query": "27.3.0",
62
- "@ultimat3/realtime": "27.3.0",
63
- "@ultimat3/render": "27.3.0",
64
- "@ultimat3/schema": "27.3.0",
65
- "@ultimat3/seo": "27.3.0",
66
- "@ultimat3/storage": "27.3.0",
67
- "@ultimat3/testing": "27.3.0",
68
- "@ultimat3/time": "27.3.0",
42
+ "@ultimat3/action": "27.5.0",
43
+ "@ultimat3/admin": "27.5.0",
44
+ "@ultimat3/ai": "27.5.0",
45
+ "@ultimat3/auth": "27.5.0",
46
+ "@ultimat3/cache": "27.5.0",
47
+ "@ultimat3/core": "27.5.0",
48
+ "@ultimat3/db": "27.5.0",
49
+ "@ultimat3/entity": "27.5.0",
50
+ "@ultimat3/flags": "27.5.0",
51
+ "@ultimat3/http": "27.5.0",
52
+ "@ultimat3/i18n": "27.5.0",
53
+ "@ultimat3/jobs": "27.5.0",
54
+ "@ultimat3/mail": "27.5.0",
55
+ "@ultimat3/manifest": "27.5.0",
56
+ "@ultimat3/mcp": "27.5.0",
57
+ "@ultimat3/money": "27.5.0",
58
+ "@ultimat3/notify": "27.5.0",
59
+ "@ultimat3/policy": "27.5.0",
60
+ "@ultimat3/pwa": "27.5.0",
61
+ "@ultimat3/query": "27.5.0",
62
+ "@ultimat3/realtime": "27.5.0",
63
+ "@ultimat3/render": "27.5.0",
64
+ "@ultimat3/schema": "27.5.0",
65
+ "@ultimat3/seo": "27.5.0",
66
+ "@ultimat3/storage": "27.5.0",
67
+ "@ultimat3/testing": "27.5.0",
68
+ "@ultimat3/time": "27.5.0",
69
69
  "babel-preset-solid": "^1.9.15"
70
70
  }
71
71
  }
@@ -0,0 +1,46 @@
1
+ // The one feature layout, made a build error: a primitive module `actions.ts` (or `live`,
2
+ // `queries`, `jobs`, `tasks`) beside the directory of the same name is refused. `x g` writes the
3
+ // directory form — `<slice>/actions/<name>.ts`, one primitive per file — and an app that also keeps
4
+ // `actions.ts` has two homes for one kind of thing, where `./actions` resolves to the file and never
5
+ // the directory. A component module beside its folder (`ui.tsx` + `ui/`) is not a primitive's home,
6
+ // and `x g resource` writes exactly that pair.
7
+
8
+ import { ERROR_DOCS_URL } from '@ultimat3/core';
9
+ import type { SourceFile } from './app-boundaries';
10
+ import type { Finding } from './output';
11
+
12
+ /** The directories `x g` writes primitives into — the only names the refusal reads. */
13
+ export const PRIMITIVE_DIRS: ReadonlySet<string> = new Set([
14
+ 'actions',
15
+ 'live',
16
+ 'queries',
17
+ 'jobs',
18
+ 'tasks',
19
+ ]);
20
+
21
+ const MODULE = /^(?<stem>.+)\.(?:ts|tsx)$/;
22
+
23
+ /** Every primitive module `X.ts` among `files` beside a directory `X/` holding another of them. */
24
+ export function siblingModuleFindings(files: readonly SourceFile[]): readonly Finding[] {
25
+ const directories = new Set<string>();
26
+ for (const { path } of files) {
27
+ const parts = path.split('/');
28
+ for (let depth = 1; depth < parts.length; depth += 1) {
29
+ directories.add(parts.slice(0, depth).join('/'));
30
+ }
31
+ }
32
+ return files.flatMap(({ path }): Finding[] => {
33
+ const stem = MODULE.exec(path)?.groups?.['stem'];
34
+ const name = stem?.split('/').at(-1) ?? '';
35
+ if (stem === undefined || !PRIMITIVE_DIRS.has(name) || !directories.has(stem)) return [];
36
+ return [
37
+ {
38
+ code: 'X_LAYOUT_SIBLING_MODULE',
39
+ cause: `${path} sits beside the directory ${stem}/ — \`./${name}\` resolves to the file, and the directory form is the one layout`,
40
+ fix: `x verify --only boundaries --json # move each declaration in ${path} to ${stem}/<its-export-in-kebab-case>.ts, then delete ${path}`,
41
+ docs: ERROR_DOCS_URL,
42
+ at: path,
43
+ },
44
+ ];
45
+ });
46
+ }
@@ -5,6 +5,7 @@
5
5
 
6
6
  import type { SourceFile } from './app-boundaries';
7
7
  import { appImportGraph, checkImportRules, readAppSources } from './app-boundaries';
8
+ import { siblingModuleFindings } from './app-layout';
8
9
  import { appTransportFindings } from './app-transport';
9
10
  import { planBoundaryCuts } from './boundary-cuts';
10
11
  import type { Finding } from './output';
@@ -44,5 +45,6 @@ export async function appBoundaryFindings(
44
45
  return [
45
46
  ...withCutEdits(checkImportRules(files), appImportGraph(files)),
46
47
  ...(await appTransportFindings(root, files)),
48
+ ...siblingModuleFindings(files),
47
49
  ];
48
50
  }
@@ -232,6 +232,7 @@ export const CLI_OWNED_ERROR_CODES = [
232
232
  'X_PACKAGE_TEST_ONLY_SHIPPED',
233
233
  'X_ISLAND_MODULE_DUPLICATED',
234
234
  'X_BACKFILL_SLICE_ENTITY',
235
+ 'X_LAYOUT_SIBLING_MODULE',
235
236
  ] as const;
236
237
 
237
238
  /**
@@ -420,6 +421,7 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
420
421
  X_PACKAGE_TEST_ONLY_SHIPPED: 'a test-only module ships in the tarball',
421
422
  X_ISLAND_MODULE_DUPLICATED: 'An island chunk bundles one module more than once',
422
423
  X_BACKFILL_SLICE_ENTITY: 'the slice holds no entity a generated backfill can sweep',
424
+ X_LAYOUT_SIBLING_MODULE: 'a primitive module beside its directory',
423
425
  };
424
426
 
425
427
  // One unconditional call, so a second package claiming one of the CLI's codes throws
package/src/mcp-errors.ts CHANGED
@@ -302,6 +302,8 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
302
302
  'gh issue create --repo developerz-ai/ultimate --title "X_ISLAND_MODULE_DUPLICATED" # paste this error\'s --json as the body: the framework bundled one of its own modules twice',
303
303
  X_BACKFILL_SLICE_ENTITY:
304
304
  'x errors explain X_BACKFILL_SLICE_ENTITY --json # the refusal itself names the x g job line for this slice',
305
+ X_LAYOUT_SIBLING_MODULE:
306
+ 'x verify --only boundaries --json # then move each declaration in X.ts to X/<name>.ts, one primitive per file',
305
307
  };
306
308
 
307
309
  const isCliCode = (code: string): code is CliErrorCode =>
package/src/role-start.ts CHANGED
@@ -39,7 +39,7 @@ import { rolesUnderRealtime } from './role-realtime';
39
39
  import { startReplicator } from './role-replicator';
40
40
  import type { RunningRoles, StartRolesOptions } from './role-start-types';
41
41
  import { DEV_ROLES } from './role-start-types';
42
- import { prepareSync, type RunningSync } from './role-sync';
42
+ import { prepareSync, type RunningSync, syncReadsChanges } from './role-sync';
43
43
  import { startWorkerWake } from './role-wake';
44
44
  import { devHooks } from './runtime-hooks';
45
45
  import { workerOptionsFor } from './runtime-jobs';
@@ -393,6 +393,7 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
393
393
  dbMode: options.runtime.services.db.mode,
394
394
  transport: options.runtime.transport.name,
395
395
  replicatorHere: replicator !== null,
396
+ readsChanges: syncReadsChanges(),
396
397
  });
397
398
  started.push(async () => live.stop());
398
399
  // The web server and the sync node mark ready when they bind; a process with neither — a
package/src/role-sync.ts CHANGED
@@ -14,6 +14,7 @@ import { listQueries } from '@ultimat3/query';
14
14
  import type { SyncNode, SyncWs } from '@ultimat3/realtime/server';
15
15
  import {
16
16
  ChannelHub,
17
+ describeChannels,
17
18
  LiveQueryRegistry,
18
19
  listenSyncNode,
19
20
  liveQueryDefinition,
@@ -195,6 +196,19 @@ export function registerLiveQueries(options: StartRolesOptions): LiveQueryRegist
195
196
  return registry;
196
197
  }
197
198
 
199
+ /**
200
+ * Whether a node in this process would read a committed change at all: a `live: true` query, or a
201
+ * declared channel that lists `records`. What is left when neither exists is events channels, and
202
+ * those ride the transport from whoever publishes them (`publishChannelEvent`) — so the boot asks
203
+ * this before it holds a real database to having a replicator (`runtime-live-feed.ts`).
204
+ */
205
+ export function syncReadsChanges(): boolean {
206
+ return (
207
+ listQueries().some((target) => target.isLive) ||
208
+ describeChannels().some((declared) => declared.records.length > 0)
209
+ );
210
+ }
211
+
198
212
  /**
199
213
  * Who dialled the socket, resolved by `@ultimat3/http`'s `clientAddress()` — the rule the web role's
200
214
  * `ctx.ip` follows, at the same `TRUSTED_PROXY_HOPS`. It keys an anonymous live reader's rate
@@ -40,6 +40,12 @@ export interface LiveFeedInput {
40
40
  readonly transport: string;
41
41
  /** Whether the `replicator` role runs in THIS process. */
42
42
  readonly replicatorHere: boolean;
43
+ /**
44
+ * Whether anything this node serves reads a committed change: a `live: true` query, or a channel
45
+ * that lists `records` (`role-sync.ts`'s `syncReadsChanges`). An events-only channel does not —
46
+ * the app publishes its frames through the transport — so such a node needs no replicator.
47
+ */
48
+ readonly readsChanges: boolean;
43
49
  }
44
50
 
45
51
  /** The label the `x dev` boot line carries beside `db=`, `events=` and `storage=`. */
@@ -49,11 +55,16 @@ export const liveFeedLabel = (feed: LiveFeed): string => `live=${feed}`;
49
55
  * `embedded` → the in-process bridge; anything else → replication, which is the WAL decoder's
50
56
  * job whether the replicator role runs in this process or another. Never both: with a real
51
57
  * database the decoder already delivers this process's own writes, and a bridge beside it would
52
- * deliver every one of them twice. No sync node, no feed to speak of.
58
+ * deliver every one of them twice. No sync node, no feed to speak of — and none for a node on a
59
+ * real database that serves only events channels, which the transport alone carries.
53
60
  */
54
61
  export async function startLiveFeed(input: LiveFeedInput): Promise<RunningLiveFeed> {
55
62
  if (input.sync === null) return { feed: 'none', bridge: null, stop: () => undefined };
56
63
  if (input.dbMode !== 'embedded') {
64
+ // Nothing here reads the log, so nothing is owed one: refusing this node demanded a
65
+ // replication slot (and the cluster-wide REPLICATION grant) to carry channel events the
66
+ // write-ahead log never held. `none`, said on the boot line, never a `replication` nobody runs.
67
+ if (!input.readsChanges) return { feed: 'none', bridge: null, stop: () => undefined };
57
68
  // A decoder this node can hear: in this process, or on a bus other processes share. Neither
58
69
  // is a sync node that reported `replication` while nothing could ever reach it.
59
70
  if (input.transport === 'in-process' && !input.replicatorHere)
@@ -27,7 +27,7 @@ import {
27
27
  setMailDriver,
28
28
  } from '@ultimat3/mail';
29
29
  import type { Transport, TransportSelection } from '@ultimat3/realtime/server';
30
- import { selectTransport } from '@ultimat3/realtime/server';
30
+ import { selectTransport, setChannelTransport } from '@ultimat3/realtime/server';
31
31
  import type { Storage } from '@ultimat3/storage';
32
32
  import { defineStorage, localDriver, s3Driver, usesDevStorageSecret } from '@ultimat3/storage';
33
33
  import { loadAppConfig } from './app-config-load';
@@ -415,6 +415,10 @@ export async function startServices(
415
415
  started.push(() => bus.transport.close());
416
416
  transport = bus.transport;
417
417
  }
418
+ // The bus an app's own code publishes a channel event on (`publishChannelEvent`), in EVERY
419
+ // role: the hub is the `sync` role's, so a job or an action reached none, and an events-only
420
+ // channel — the one kind that needs no replicator — could be fed by nothing but a socket.
421
+ started.push(setChannelTransport(transport));
418
422
  // Only a transport that can be disconnected gets a check. `NatsTransport.connected` is a
419
423
  // synchronous getter over the client's own state, so no probe is needed; the in-process bus
420
424
  // has nothing to lose a connection to, and a check that can only answer `true` is a number in