nucleus-core-ts 0.9.845 → 0.9.847

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.
@@ -2,10 +2,12 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useState } from 'react';
4
4
  import { useIntegrationsStore } from '../store';
5
+ import { useLabels } from '../text/context';
5
6
  import { integrationsPageTheme as theme } from '../theme';
6
7
  import { deleteIsConfirmed } from './deleteIsConfirmed';
7
8
  import { Notice } from './Primitives';
8
9
  export function ConnectionDelete({ actions, source, onDeleted }) {
10
+ const labels = useLabels();
9
11
  const store = useIntegrationsStore();
10
12
  const [asking, setAsking] = useState(false);
11
13
  const [typed, setTyped] = useState('');
@@ -18,11 +20,12 @@ export function ConnectionDelete({ actions, source, onDeleted }) {
18
20
  actions.deleteSource(source.id).then((done)=>{
19
21
  setBusy(false);
20
22
  if (!done) {
21
- store.setError('The connection was not deleted.');
23
+ store.setError(labels.connections.deleteFailed);
22
24
  return;
23
25
  }
24
26
  setAsking(false);
25
27
  setTyped('');
28
+ store.removeSource(source.id);
26
29
  store.selectSource(null);
27
30
  onDeleted?.();
28
31
  }).catch((error)=>{
@@ -38,13 +41,13 @@ export function ConnectionDelete({ actions, source, onDeleted }) {
38
41
  children: [
39
42
  /*#__PURE__*/ _jsx("h3", {
40
43
  className: theme.card.title,
41
- children: "Remove this connection"
44
+ children: labels.connections.deleteHeading
42
45
  }),
43
46
  asking ? null : /*#__PURE__*/ _jsx("button", {
44
47
  className: theme.button.danger,
45
48
  onClick: ()=>setAsking(true),
46
49
  type: "button",
47
- children: "Delete"
50
+ children: labels.connections.deleteStart
48
51
  })
49
52
  ]
50
53
  }),
@@ -52,27 +55,17 @@ export function ConnectionDelete({ actions, source, onDeleted }) {
52
55
  className: theme.card.body,
53
56
  children: asking ? /*#__PURE__*/ _jsxs(_Fragment, {
54
57
  children: [
55
- /*#__PURE__*/ _jsxs(Notice, {
56
- title: "This takes its calls and mappings with it",
58
+ /*#__PURE__*/ _jsx(Notice, {
59
+ title: labels.connections.deleteWarnTitle,
57
60
  tone: "danger",
58
- children: [
59
- "Everything configured under “",
60
- source.name,
61
- "” goes: the calls, the mappings and the record of what they imported. The rows already written into your tables stay where they are — but nothing will be able to undo the imports that put them there."
62
- ]
61
+ children: labels.connections.deleteWarnBody(source.name)
63
62
  }),
64
63
  /*#__PURE__*/ _jsxs("label", {
65
64
  className: theme.field.wrapper,
66
65
  children: [
67
- /*#__PURE__*/ _jsxs("span", {
66
+ /*#__PURE__*/ _jsx("span", {
68
67
  className: theme.field.label,
69
- children: [
70
- "Type ",
71
- /*#__PURE__*/ _jsx("strong", {
72
- children: source.name
73
- }),
74
- " to confirm"
75
- ]
68
+ children: labels.connections.deleteTypeToConfirm
76
69
  }),
77
70
  /*#__PURE__*/ _jsx("input", {
78
71
  className: theme.field.input,
@@ -94,21 +87,21 @@ export function ConnectionDelete({ actions, source, onDeleted }) {
94
87
  setTyped('');
95
88
  },
96
89
  type: "button",
97
- children: "Cancel"
90
+ children: labels.common.cancel
98
91
  }),
99
92
  /*#__PURE__*/ _jsx("button", {
100
93
  className: theme.button.danger,
101
94
  disabled: busy || !confirmed,
102
95
  onClick: remove,
103
96
  type: "button",
104
- children: busy ? 'Deleting…' : 'Delete this connection'
97
+ children: busy ? labels.common.deleting : labels.connections.deleteConfirm
105
98
  })
106
99
  ]
107
100
  })
108
101
  ]
109
102
  }) : /*#__PURE__*/ _jsx("p", {
110
103
  className: theme.field.hint,
111
- children: "A connection can be turned off instead — that stops every call and import through it and keeps the settings. Deleting is for one that should not have existed."
104
+ children: labels.connections.deleteHint
112
105
  })
113
106
  })
114
107
  ]
@@ -66,13 +66,9 @@ export function ConnectionTest({ actions, source, loading = false }) {
66
66
  /*#__PURE__*/ _jsxs("div", {
67
67
  className: theme.card.body,
68
68
  children: [
69
- source ? /*#__PURE__*/ _jsxs("p", {
69
+ source ? /*#__PURE__*/ _jsx("p", {
70
70
  className: theme.field.hint,
71
- children: [
72
- "Calls ",
73
- source.baseUrl,
74
- " with the settings and credentials as they are stored right now."
75
- ]
71
+ children: labels.auth.testCalls(source.baseUrl)
76
72
  }) : /*#__PURE__*/ _jsx("p", {
77
73
  className: theme.field.hint,
78
74
  children: labels.auth.testSaveFirst
@@ -43,7 +43,12 @@ export function IntegrationsPage({ title = 'Integrations', subtitle = 'Connect a
43
43
  * detail treated the missing record as still loading and sat on skeletons
44
44
  * until the page was reloaded by hand. The write itself had succeeded.
45
45
  */ const [sourcesReloadToken, setSourcesReloadToken] = useState(0);
46
- const handleSourceSaved = ()=>{
46
+ const handleSourceSaved = (saved)=>{
47
+ // Into the store FIRST. Bumping the token only tells the rail to refetch,
48
+ // and choosing a connection unmounts the rail — so on a NEW connection the
49
+ // token reached nobody and the detail below could not find the record it
50
+ // had just been switched to.
51
+ store.upsertSource(saved);
47
52
  setSourcesReloadToken((token)=>token + 1);
48
53
  };
49
54
  /**
@@ -261,7 +261,7 @@ export function MappingEditor({ mapping, targets, targetsLoading, endpoints, fre
261
261
  disabled: busy,
262
262
  onClick: ()=>onDelete(draft.id ?? ''),
263
263
  type: "button",
264
- children: deleting ? 'Deleting…' : 'Delete'
264
+ children: deleting ? labels.common.deleting : labels.common.delete
265
265
  }) : null,
266
266
  /*#__PURE__*/ _jsx("button", {
267
267
  className: theme.button.secondary,
@@ -282,7 +282,7 @@ export function MappingEditor({ mapping, targets, targetsLoading, endpoints, fre
282
282
  disabled: busy || issues.length > 0,
283
283
  onClick: ()=>onSave(draft),
284
284
  type: "button",
285
- children: saving ? 'Saving…' : 'Save mapping'
285
+ children: saving ? labels.common.saving : labels.mappings.save
286
286
  })
287
287
  ]
288
288
  })
@@ -60,13 +60,10 @@ import { Notice } from './Primitives';
60
60
  className: theme.field.hint,
61
61
  children: labels.endpoints.bindingsNone
62
62
  }) : null,
63
- unbound.length > 0 ? /*#__PURE__*/ _jsxs(Notice, {
63
+ unbound.length > 0 ? /*#__PURE__*/ _jsx(Notice, {
64
64
  title: labels.endpoints.bindingsTitle,
65
65
  tone: "warning",
66
- children: [
67
- unbound.join(', '),
68
- " — an import cannot run until every one of them is bound. Add a binding for each below."
69
- ]
66
+ children: labels.endpoints.bindingsUnbound(unbound.join(', '))
70
67
  }) : null,
71
68
  value.map((binding, index)=>/*#__PURE__*/ _jsx(ParamBindingRow, {
72
69
  binding: binding,
@@ -1,5 +1,6 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useLabels } from '../text/context';
3
4
  import { integrationsPageTheme } from '../theme';
4
5
  import { Notice } from './Primitives';
5
6
  /**
@@ -10,6 +11,7 @@ import { Notice } from './Primitives';
10
11
  * sure?" transfers no information, so this one refuses to be that.
11
12
  */ const theme = integrationsPageTheme;
12
13
  export function RunConfirmStep({ lines, counted, working, onConfirm, onDismiss }) {
14
+ const labels = useLabels();
13
15
  return /*#__PURE__*/ _jsxs(Notice, {
14
16
  tone: "danger",
15
17
  title: "This import destroys data. Read what it removes, then confirm.",
@@ -32,7 +34,7 @@ export function RunConfirmStep({ lines, counted, working, onConfirm, onDismiss }
32
34
  disabled: working,
33
35
  onClick: onConfirm,
34
36
  type: "button",
35
- children: working ? 'Starting…' : 'Run it and remove those rows'
37
+ children: working ? labels.runs.starting : 'Run it and remove those rows'
36
38
  }),
37
39
  /*#__PURE__*/ _jsx("button", {
38
40
  className: theme.button.secondary,
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { cn } from '../../../utils/cn';
4
+ import { useLabels } from '../text/context';
4
5
  import { integrationsPageTheme } from '../theme';
5
6
  /**
6
7
  * How far the run this panel started has got.
@@ -17,6 +18,7 @@ const PHASE_LABEL = {
17
18
  done: 'Finished'
18
19
  };
19
20
  export function RunProgressBar({ runId, progress, cancelling, onCancel }) {
21
+ const labels = useLabels();
20
22
  const total = progress?.total ?? 0;
21
23
  const processed = progress?.processed ?? 0;
22
24
  const determinate = total > 0;
@@ -68,7 +70,7 @@ export function RunProgressBar({ runId, progress, cancelling, onCancel }) {
68
70
  disabled: cancelling,
69
71
  onClick: onCancel,
70
72
  type: "button",
71
- children: cancelling ? 'Cancelling…' : 'Cancel'
73
+ children: cancelling ? labels.runs.cancelling : labels.runs.cancel
72
74
  })
73
75
  ]
74
76
  })
@@ -1,15 +1,28 @@
1
1
  'use client';
2
2
  import { batch, createStore } from 'h-state';
3
3
  import { initial } from './state';
4
+ import { isNewTo, upsertInto } from './upsertInto';
4
5
  export const { useStore: useIntegrationsStore } = createStore(initial, {
5
6
  setSources: (store)=>(items, total)=>batch(()=>{
6
7
  store.sources = items;
7
8
  store.sourcesTotal = total;
8
9
  store.sourcesLoading = false;
9
10
  }),
10
- setSourcesPage: (store)=>(page)=>{
11
- store.sourcesPage = page;
12
- },
11
+ upsertSource: (store)=>(source)=>batch(()=>{
12
+ // The count moves only for an addition — an edit that bumped the total
13
+ // would make the rail claim a connection nobody can find.
14
+ if (isNewTo(store.sources, source)) store.sourcesTotal = store.sourcesTotal + 1;
15
+ store.sources = upsertInto(store.sources, source);
16
+ }),
17
+ removeSource: (store)=>(id)=>batch(()=>{
18
+ const kept = store.sources.filter((candidate)=>candidate.id !== id);
19
+ // Only when it was actually on this page — the count must not fall for a
20
+ // record the list never held.
21
+ if (kept.length !== store.sources.length) {
22
+ store.sourcesTotal = Math.max(0, store.sourcesTotal - 1);
23
+ store.sources = kept;
24
+ }
25
+ }),
13
26
  setSourcesSearch: (store)=>(search)=>batch(()=>{
14
27
  store.sourcesSearch = search;
15
28
  // A new search is a new result set; staying on page 4 would show an empty
@@ -53,12 +66,6 @@ export const { useStore: useIntegrationsStore } = createStore(initial, {
53
66
  store.endpointsTotal = total;
54
67
  store.endpointsLoading = false;
55
68
  }),
56
- setEndpointsTotal: (store)=>(total)=>{
57
- store.endpointsTotal = total;
58
- },
59
- setEndpointsPage: (store)=>(page)=>{
60
- store.endpointsPage = page;
61
- },
62
69
  setEndpointsSearch: (store)=>(search)=>batch(()=>{
63
70
  store.endpointsSearch = search;
64
71
  store.endpointsPage = 1;
@@ -80,9 +87,6 @@ export const { useStore: useIntegrationsStore } = createStore(initial, {
80
87
  setMappingsTotal: (store)=>(total)=>{
81
88
  store.mappingsTotal = total;
82
89
  },
83
- setMappingsPage: (store)=>(page)=>{
84
- store.mappingsPage = page;
85
- },
86
90
  setMappingsSearch: (store)=>(search)=>batch(()=>{
87
91
  store.mappingsSearch = search;
88
92
  store.mappingsPage = 1;
@@ -107,12 +111,6 @@ export const { useStore: useIntegrationsStore } = createStore(initial, {
107
111
  store.runsTotal = total;
108
112
  store.runsLoading = false;
109
113
  }),
110
- setRunsTotal: (store)=>(total)=>{
111
- store.runsTotal = total;
112
- },
113
- setRunsPage: (store)=>(page)=>{
114
- store.runsPage = page;
115
- },
116
114
  setRunsLoading: (store)=>(loading)=>{
117
115
  store.runsLoading = loading;
118
116
  },
@@ -15,7 +15,6 @@ export type State = {
15
15
  [key: string]: unknown;
16
16
  sources: IntegrationSource[];
17
17
  sourcesTotal: number;
18
- sourcesPage: number;
19
18
  sourcesSearch: string;
20
19
  sourcesLoading: boolean;
21
20
  selectedSourceId: string | null;
@@ -33,19 +32,16 @@ export type State = {
33
32
  focusStep: 'mapping' | 'import';
34
33
  endpoints: IntegrationEndpoint[];
35
34
  endpointsTotal: number;
36
- endpointsPage: number;
37
35
  endpointsSearch: string;
38
36
  endpointsLoading: boolean;
39
37
  selectedEndpointId: string | null;
40
38
  mappings: IntegrationMapping[];
41
39
  mappingsTotal: number;
42
- mappingsPage: number;
43
40
  mappingsSearch: string;
44
41
  mappingsLoading: boolean;
45
42
  selectedMappingId: string | null;
46
43
  runs: IntegrationRun[];
47
44
  runsTotal: number;
48
- runsPage: number;
49
45
  runsLoading: boolean;
50
46
  /** The real shape of the selected endpoint's response, once sampled. */
51
47
  sample: EndpointSampleValue | null;
@@ -62,7 +58,18 @@ export type State = {
62
58
  export type Actions = {
63
59
  [key: string]: unknown;
64
60
  setSources: (items: IntegrationSource[], total: number) => void;
65
- setSourcesPage: (page: number) => void;
61
+ /**
62
+ * Put one connection into the list, whether or not it is already there.
63
+ *
64
+ * The rail is what fetches connections, and choosing one UNMOUNTS the rail —
65
+ * so a connection created from the form was selected into a list that had
66
+ * never heard of it, and every panel below sat on skeletons until the page
67
+ * was reloaded by hand. The save already returns the record; this is where it
68
+ * goes so the detail can find it.
69
+ */
70
+ upsertSource: (source: IntegrationSource) => void;
71
+ /** Drop one connection from the list, for when it has just been deleted. */
72
+ removeSource: (id: string) => void;
66
73
  setSourcesSearch: (search: string) => void;
67
74
  setSourcesLoading: (loading: boolean) => void;
68
75
  selectSource: (id: string | null) => void;
@@ -75,20 +82,15 @@ export type Actions = {
75
82
  * The step strip reports how far a connection has got, and it cannot wait for
76
83
  * the tab that lists the rows to have been opened — so it counts directly.
77
84
  */
78
- setEndpointsTotal: (total: number) => void;
79
- setEndpointsPage: (page: number) => void;
80
85
  setEndpointsSearch: (search: string) => void;
81
86
  setEndpointsLoading: (loading: boolean) => void;
82
87
  selectEndpoint: (id: string | null) => void;
83
88
  setMappings: (items: IntegrationMapping[], total: number) => void;
84
89
  setMappingsTotal: (total: number) => void;
85
- setMappingsPage: (page: number) => void;
86
90
  setMappingsSearch: (search: string) => void;
87
91
  setMappingsLoading: (loading: boolean) => void;
88
92
  selectMapping: (id: string | null) => void;
89
93
  setRuns: (items: IntegrationRun[], total: number) => void;
90
- setRunsTotal: (total: number) => void;
91
- setRunsPage: (page: number) => void;
92
94
  setRunsLoading: (loading: boolean) => void;
93
95
  setSample: (sample: EndpointSampleValue | null) => void;
94
96
  setSampling: (sampling: boolean) => void;
@@ -1,7 +1,6 @@
1
1
  export const initial = {
2
2
  sources: [],
3
3
  sourcesTotal: 0,
4
- sourcesPage: 1,
5
4
  sourcesSearch: '',
6
5
  sourcesLoading: false,
7
6
  selectedSourceId: null,
@@ -10,19 +9,16 @@ export const initial = {
10
9
  focusStep: 'mapping',
11
10
  endpoints: [],
12
11
  endpointsTotal: 0,
13
- endpointsPage: 1,
14
12
  endpointsSearch: '',
15
13
  endpointsLoading: false,
16
14
  selectedEndpointId: null,
17
15
  mappings: [],
18
16
  mappingsTotal: 0,
19
- mappingsPage: 1,
20
17
  mappingsSearch: '',
21
18
  mappingsLoading: false,
22
19
  selectedMappingId: null,
23
20
  runs: [],
24
21
  runsTotal: 0,
25
- runsPage: 1,
26
22
  runsLoading: false,
27
23
  sample: null,
28
24
  sampling: false,
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Where a saved record goes in a list that may or may not already hold it.
3
+ *
4
+ * Kept as a function so the rule can be tested apart from the store: the panel
5
+ * unmounts the connection rail the moment a connection is chosen, so a newly
6
+ * created connection is selected into a list that never fetched it. Putting the
7
+ * returned record in by hand is what stops the detail below from waiting for a
8
+ * fetch that no mounted component will make.
9
+ *
10
+ * A new record goes to the FRONT — it is the one just made, and the operator is
11
+ * about to be switched to it; an existing one is replaced where it already sits,
12
+ * so an edit never reorders a list somebody is reading.
13
+ */
14
+ export declare function upsertInto<T extends {
15
+ id: string;
16
+ }>(items: T[], record: T): T[];
17
+ /** Whether `record` would be an addition rather than a replacement. */
18
+ export declare function isNewTo<T extends {
19
+ id: string;
20
+ }>(items: T[], record: T): boolean;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Where a saved record goes in a list that may or may not already hold it.
3
+ *
4
+ * Kept as a function so the rule can be tested apart from the store: the panel
5
+ * unmounts the connection rail the moment a connection is chosen, so a newly
6
+ * created connection is selected into a list that never fetched it. Putting the
7
+ * returned record in by hand is what stops the detail below from waiting for a
8
+ * fetch that no mounted component will make.
9
+ *
10
+ * A new record goes to the FRONT — it is the one just made, and the operator is
11
+ * about to be switched to it; an existing one is replaced where it already sits,
12
+ * so an edit never reorders a list somebody is reading.
13
+ */ export function upsertInto(items, record) {
14
+ return items.some((candidate)=>candidate.id === record.id) ? items.map((candidate)=>candidate.id === record.id ? record : candidate) : [
15
+ record,
16
+ ...items
17
+ ];
18
+ }
19
+ /** Whether `record` would be an addition rather than a replacement. */ export function isNewTo(items, record) {
20
+ return !items.some((candidate)=>candidate.id === record.id);
21
+ }
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { isNewTo, upsertInto } from './upsertInto';
3
+ const a = {
4
+ id: 'a',
5
+ name: 'First'
6
+ };
7
+ const b = {
8
+ id: 'b',
9
+ name: 'Second'
10
+ };
11
+ describe('upsertInto', ()=>{
12
+ it('puts a new record at the front', ()=>{
13
+ expect(upsertInto([
14
+ a
15
+ ], b)).toEqual([
16
+ b,
17
+ a
18
+ ]);
19
+ });
20
+ it('adds to an empty list', ()=>{
21
+ expect(upsertInto([], a)).toEqual([
22
+ a
23
+ ]);
24
+ });
25
+ it('replaces an existing record where it already sits', ()=>{
26
+ const renamed = {
27
+ id: 'a',
28
+ name: 'Renamed'
29
+ };
30
+ expect(upsertInto([
31
+ b,
32
+ a
33
+ ], renamed)).toEqual([
34
+ b,
35
+ renamed
36
+ ]);
37
+ });
38
+ it('never duplicates — saving the same record twice leaves one', ()=>{
39
+ const once = upsertInto([], a);
40
+ expect(upsertInto(once, {
41
+ id: 'a',
42
+ name: 'Again'
43
+ })).toHaveLength(1);
44
+ });
45
+ it('leaves the original array untouched', ()=>{
46
+ const items = [
47
+ a
48
+ ];
49
+ upsertInto(items, b);
50
+ expect(items).toEqual([
51
+ a
52
+ ]);
53
+ });
54
+ });
55
+ describe('isNewTo', ()=>{
56
+ it('is true only for a record the list does not hold', ()=>{
57
+ expect(isNewTo([
58
+ a
59
+ ], b)).toBe(true);
60
+ expect(isNewTo([
61
+ a
62
+ ], {
63
+ id: 'a',
64
+ name: 'Renamed'
65
+ })).toBe(false);
66
+ });
67
+ });
@@ -63,6 +63,14 @@ export type IntegrationsLabels = {
63
63
  disabledBadge: string;
64
64
  saveChanges: string;
65
65
  createSubmit: string;
66
+ deleteHeading: string;
67
+ deleteStart: string;
68
+ deleteWarnTitle: string;
69
+ deleteWarnBody: (name: string) => string;
70
+ deleteTypeToConfirm: string;
71
+ deleteConfirm: string;
72
+ deleteHint: string;
73
+ deleteFailed: string;
66
74
  };
67
75
  runs: {
68
76
  /** The one-line summary above the buttons. */
@@ -72,6 +80,7 @@ export type IntegrationsLabels = {
72
80
  start: string;
73
81
  starting: string;
74
82
  cancel: string;
83
+ cancelling: string;
75
84
  previewExplainTitle: string;
76
85
  previewExplainBody: string;
77
86
  historyHeading: string;
@@ -56,7 +56,15 @@
56
56
  enabledBadge: 'Enabled',
57
57
  disabledBadge: 'Off',
58
58
  saveChanges: 'Save changes',
59
- createSubmit: 'Create connection'
59
+ createSubmit: 'Create connection',
60
+ deleteHeading: 'Remove this connection',
61
+ deleteStart: 'Delete',
62
+ deleteWarnTitle: 'This takes its calls and mappings with it',
63
+ deleteWarnBody: (name)=>`Everything configured under “${name}” goes: the calls, the mappings and the record of what they imported. The rows already written into your tables stay where they are — but nothing will be able to undo the imports that put them there.`,
64
+ deleteTypeToConfirm: 'Type the connection name to confirm',
65
+ deleteConfirm: 'Delete this connection',
66
+ deleteHint: 'A connection can be turned off instead — that stops every call and import through it and keeps the settings. Deleting is for one that should not have existed.',
67
+ deleteFailed: 'The connection was not deleted.'
60
68
  },
61
69
  runs: {
62
70
  summary: (target, writeMode, missing)=>`Writes into ${target} · ${writeMode} · missing rows: ${missing}`,
@@ -65,6 +73,7 @@
65
73
  start: 'Run import',
66
74
  starting: 'Starting…',
67
75
  cancel: 'Cancel',
76
+ cancelling: 'Cancelling…',
68
77
  previewExplainTitle: 'Preview before you run',
69
78
  previewExplainBody: 'A preview reads the source without writing anything, and reports how many rows would be created, updated, skipped, and how many rows in the target the source no longer returns.',
70
79
  historyHeading: 'History',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nucleus-core-ts",
3
- "version": "0.9.845",
3
+ "version": "0.9.847",
4
4
  "description": "Production-ready, enterprise-grade TypeScript framework for building multi-tenant APIs",
5
5
  "author": "Hidayet Can Özcan <hidayetcan@gmail.com>",
6
6
  "license": "SEE LICENSE IN LICENSE",