meadow-endpoints 4.0.18 → 4.0.20

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": "meadow-endpoints",
3
- "version": "4.0.18",
3
+ "version": "4.0.20",
4
4
  "description": "Automatic API endpoints for Meadow data.",
5
5
  "main": "source/Meadow-Endpoints.js",
6
6
  "scripts": {
@@ -52,24 +52,24 @@
52
52
  "better-sqlite3": "^12.9.0",
53
53
  "chance": "^1.1.13",
54
54
  "gulp-util": "^3.0.8",
55
- "meadow-connection-sqlite": "^1.0.18",
55
+ "meadow-connection-sqlite": "^1.0.19",
56
56
  "mysql2": "^3.22.1",
57
- "orator-serviceserver-restify": "^2.0.10",
57
+ "orator-serviceserver-restify": "^2.0.11",
58
58
  "papaparse": "^5.5.3",
59
59
  "pict-docuserve": "^0.1.5",
60
- "quackage": "^1.1.2",
60
+ "quackage": "^1.2.3",
61
61
  "supertest": "^7.2.2",
62
62
  "typescript": "^5.9.3",
63
63
  "why-is-node-running": "^3.2.2"
64
64
  },
65
65
  "dependencies": {
66
66
  "async": "3.2.6",
67
- "fable": "^3.1.71",
67
+ "fable": "^3.1.72",
68
68
  "fable-serviceproviderbase": "^3.0.19",
69
69
  "JSONStream": "^1.3.5",
70
- "meadow": "^2.0.37",
70
+ "meadow": "^2.0.38",
71
71
  "meadow-filter": "^1.0.10",
72
- "orator": "^6.0.4",
72
+ "orator": "^6.1.2",
73
73
  "underscore": "^1.13.8"
74
74
  }
75
75
  }
@@ -213,6 +213,14 @@ class MeadowEndpoints
213
213
  this.connectRoute(pServiceServer, 'putWithBodyParser', `/Upsert`, this._Endpoints.Upsert, `the internal behavior _Endpoints.Upsert`);
214
214
  this.connectRoute(pServiceServer, 'putWithBodyParser', `/Upserts`, this._Endpoints.Upserts, `the internal behavior _Endpoints.Upserts`);
215
215
  this.connectRoute(pServiceServer, 'putWithBodyParser', `/Upserts/Detailed`, this._Endpoints.UpsertsDetailed, `the internal behavior _Endpoints.UpsertsDetailed`);
216
+ // REST-idiomatic PUT/PATCH-by-id: the URL path holds the primary key,
217
+ // matching GET/:IDRecord and DELETE/:IDRecord. The Update endpoint
218
+ // merges pRequest.params.IDRecord into the body so the row is updated
219
+ // in-place — no GET → DELETE → INSERT dance, primary key preserved.
220
+ // Registered AFTER the literal /Upsert(s) routes so those win on the
221
+ // :IDRecord match.
222
+ this.connectRoute(pServiceServer, 'putWithBodyParser', `/:IDRecord`, this._Endpoints.Update, `the internal behavior _Endpoints.Update (by-id PUT)`);
223
+ this.connectRoute(pServiceServer, 'patchWithBodyParser', `/:IDRecord`, this._Endpoints.Update, `the internal behavior _Endpoints.Update (by-id PATCH)`);
216
224
  }
217
225
  if (this._EnabledBehaviorSets.Delete)
218
226
  {
@@ -12,10 +12,26 @@ const doAPIEndpointUpdate = function(pRequest, pResponse, fNext)
12
12
  [
13
13
  (fStageComplete) =>
14
14
  {
15
- if (typeof(pRequest.body) !== 'object')
15
+ if (typeof(pRequest.body) !== 'object' || pRequest.body === null)
16
16
  {
17
17
  return fStageComplete(this.ErrorHandler.getError('Record update failure - a valid record is required.', 400));
18
18
  }
19
+
20
+ // PUT|PATCH /Entity/:IDRecord puts the primary key in the URL path
21
+ // (REST-idiomatic, parallels GET/:IDRecord and DELETE/:IDRecord).
22
+ // When present, the URL ID is authoritative and overrides anything
23
+ // in the body. This is what lets clients update-in-place without
24
+ // the GET → DELETE → INSERT churn that loses the primary key.
25
+ if (pRequest.params && pRequest.params.IDRecord)
26
+ {
27
+ let tmpURLID = pRequest.params.IDRecord;
28
+ if (typeof(tmpURLID) === 'string' && /^-?\d+$/.test(tmpURLID))
29
+ {
30
+ tmpURLID = Number(tmpURLID);
31
+ }
32
+ pRequest.body[this.DAL.defaultIdentifier] = tmpURLID;
33
+ }
34
+
19
35
  if (pRequest.body[this.DAL.defaultIdentifier] < 1)
20
36
  {
21
37
  return fStageComplete(this.ErrorHandler.getError('Record update failure - a valid record ID is required in the passed-in record.', 400));
@@ -2736,5 +2736,557 @@ suite
2736
2736
  );
2737
2737
  }
2738
2738
  );
2739
+
2740
+ // ======================================================================
2741
+ // Update by primary key via URL (PUT/PATCH /:IDRecord)
2742
+ // ----------------------------------------------------------------------
2743
+ // REST-idiomatic in-place update. URL ID is authoritative — clients no
2744
+ // longer need to GET → DELETE → INSERT to "edit" a row, which was the
2745
+ // pattern that churned auto-ids and broke ID-holding consumers.
2746
+ // ======================================================================
2747
+ suite
2748
+ (
2749
+ 'Update by primary key via URL',
2750
+ () =>
2751
+ {
2752
+ let _ByIDRecordID = 0;
2753
+
2754
+ suiteSetup
2755
+ (
2756
+ function (fDone)
2757
+ {
2758
+ _SuperTest
2759
+ .post('1.0/Book')
2760
+ .send({ Title: 'PUT-by-id seed', Genre: 'Test' })
2761
+ .end(
2762
+ (pError, pResponse) =>
2763
+ {
2764
+ let tmpResult = JSON.parse(pResponse.text);
2765
+ _ByIDRecordID = tmpResult.IDBook;
2766
+ Expect(_ByIDRecordID).to.be.above(0);
2767
+ fDone();
2768
+ }
2769
+ );
2770
+ }
2771
+ );
2772
+
2773
+ test
2774
+ (
2775
+ 'PUT /:IDRecord updates in place and preserves the primary key',
2776
+ function (fDone)
2777
+ {
2778
+ _SuperTest
2779
+ .put(`1.0/Book/${_ByIDRecordID}`)
2780
+ .send({ Title: 'PUT-by-id updated', Genre: 'Updated' })
2781
+ .end(
2782
+ (pError, pResponse) =>
2783
+ {
2784
+ Expect(pResponse.status).to.equal(200);
2785
+ let tmpResult = JSON.parse(pResponse.text);
2786
+ Expect(tmpResult.IDBook).to.equal(_ByIDRecordID);
2787
+ Expect(tmpResult.Title).to.equal('PUT-by-id updated');
2788
+ Expect(tmpResult.Genre).to.equal('Updated');
2789
+ fDone();
2790
+ }
2791
+ );
2792
+ }
2793
+ );
2794
+
2795
+ test
2796
+ (
2797
+ 'PUT /:IDRecord persists the update and the row is read back at the same ID',
2798
+ function (fDone)
2799
+ {
2800
+ _SuperTest
2801
+ .get(`1.0/Book/${_ByIDRecordID}`)
2802
+ .end(
2803
+ (pError, pResponse) =>
2804
+ {
2805
+ let tmpResult = JSON.parse(pResponse.text);
2806
+ Expect(tmpResult.IDBook).to.equal(_ByIDRecordID);
2807
+ Expect(tmpResult.Title).to.equal('PUT-by-id updated');
2808
+ fDone();
2809
+ }
2810
+ );
2811
+ }
2812
+ );
2813
+
2814
+ test
2815
+ (
2816
+ 'PATCH /:IDRecord behaves the same as PUT — update in place by URL ID',
2817
+ function (fDone)
2818
+ {
2819
+ _SuperTest
2820
+ .patch(`1.0/Book/${_ByIDRecordID}`)
2821
+ .send({ Title: 'PATCH-by-id', Genre: 'Patched' })
2822
+ .end(
2823
+ (pError, pResponse) =>
2824
+ {
2825
+ Expect(pResponse.status).to.equal(200);
2826
+ let tmpResult = JSON.parse(pResponse.text);
2827
+ Expect(tmpResult.IDBook).to.equal(_ByIDRecordID);
2828
+ Expect(tmpResult.Title).to.equal('PATCH-by-id');
2829
+ fDone();
2830
+ }
2831
+ );
2832
+ }
2833
+ );
2834
+
2835
+ test
2836
+ (
2837
+ 'PUT /:IDRecord — URL ID overrides any IDBook in the body',
2838
+ function (fDone)
2839
+ {
2840
+ // Send a body whose IDBook contradicts the URL. The URL
2841
+ // must win so consumers can't accidentally pivot the
2842
+ // update to a different row by stale-body cache.
2843
+ _SuperTest
2844
+ .put(`1.0/Book/${_ByIDRecordID}`)
2845
+ .send({ IDBook: 99999, Title: 'URL wins', Genre: 'Override' })
2846
+ .end(
2847
+ (pError, pResponse) =>
2848
+ {
2849
+ Expect(pResponse.status).to.equal(200);
2850
+ let tmpResult = JSON.parse(pResponse.text);
2851
+ Expect(tmpResult.IDBook).to.equal(_ByIDRecordID);
2852
+ Expect(tmpResult.Title).to.equal('URL wins');
2853
+ fDone();
2854
+ }
2855
+ );
2856
+ }
2857
+ );
2858
+
2859
+ test
2860
+ (
2861
+ 'PUT /Upsert is still routed to the upsert endpoint, not /:IDRecord',
2862
+ function (fDone)
2863
+ {
2864
+ // Regression: the literal /Upsert route must not be
2865
+ // shadowed by the new /:IDRecord parameterized route.
2866
+ _SuperTest
2867
+ .put('1.0/Book/Upsert')
2868
+ .send({ IDBook: _ByIDRecordID, Title: 'Upsert kept routed' })
2869
+ .end(
2870
+ (pError, pResponse) =>
2871
+ {
2872
+ Expect(pResponse.status).to.equal(200);
2873
+ let tmpResult = JSON.parse(pResponse.text);
2874
+ Expect(tmpResult.IDBook).to.equal(_ByIDRecordID);
2875
+ Expect(tmpResult.Title).to.equal('Upsert kept routed');
2876
+ fDone();
2877
+ }
2878
+ );
2879
+ }
2880
+ );
2881
+
2882
+ test
2883
+ (
2884
+ 'PUT /:IDRecord — full delete → put-with-same-key → re-read round trip',
2885
+ function (fDone)
2886
+ {
2887
+ // Previously this round-trip required GET → DELETE →
2888
+ // INSERT, and the new INSERT got a fresh auto-id. With
2889
+ // PUT-by-id the primary key is preserved end-to-end.
2890
+ _SuperTest
2891
+ .post('1.0/Book')
2892
+ .send({ Title: 'Round-trip seed' })
2893
+ .end(
2894
+ (pPostErr, pPostRes) =>
2895
+ {
2896
+ let tmpRecord = JSON.parse(pPostRes.text);
2897
+ let tmpID = tmpRecord.IDBook;
2898
+ Expect(tmpID).to.be.above(0);
2899
+
2900
+ _SuperTest
2901
+ .put(`1.0/Book/${tmpID}`)
2902
+ .send({ Title: 'Round-trip updated' })
2903
+ .end(
2904
+ (pPutErr, pPutRes) =>
2905
+ {
2906
+ Expect(pPutRes.status).to.equal(200);
2907
+
2908
+ _SuperTest
2909
+ .get(`1.0/Book/${tmpID}`)
2910
+ .end(
2911
+ (pGetErr, pGetRes) =>
2912
+ {
2913
+ let tmpReadBack = JSON.parse(pGetRes.text);
2914
+ Expect(tmpReadBack.IDBook).to.equal(tmpID);
2915
+ Expect(tmpReadBack.Title).to.equal('Round-trip updated');
2916
+ fDone();
2917
+ }
2918
+ );
2919
+ }
2920
+ );
2921
+ }
2922
+ );
2923
+ }
2924
+ );
2925
+ }
2926
+ );
2927
+
2928
+ // ======================================================================
2929
+ // Soft-deleted collision rename on INSERT
2930
+ // ----------------------------------------------------------------------
2931
+ // New Widget table with a plain UNIQUE INDEX on Code (no
2932
+ // `WHERE Deleted=0` predicate) and a plain composite UNIQUE INDEX on
2933
+ // (Scope, Hash). Verifies that soft-deleted rows whose values would
2934
+ // collide with a new INSERT are renamed deterministically before the
2935
+ // INSERT runs, freeing the slot.
2936
+ // ======================================================================
2937
+ suite
2938
+ (
2939
+ 'Soft-deleted collision rename on INSERT',
2940
+ () =>
2941
+ {
2942
+ let _WidgetMeadow = false;
2943
+ let _WidgetEndpoints = false;
2944
+ let _DB = false;
2945
+
2946
+ const _WidgetSchema = [
2947
+ { Column: 'IDWidget', Type: 'AutoIdentity' },
2948
+ { Column: 'GUIDWidget', Type: 'AutoGUID' },
2949
+ { Column: 'CreateDate', Type: 'CreateDate' },
2950
+ { Column: 'CreatingIDUser', Type: 'CreateIDUser' },
2951
+ { Column: 'UpdateDate', Type: 'UpdateDate' },
2952
+ { Column: 'UpdatingIDUser', Type: 'UpdateIDUser' },
2953
+ { Column: 'Deleted', Type: 'Deleted' },
2954
+ { Column: 'DeleteDate', Type: 'DeleteDate' },
2955
+ { Column: 'DeletingIDUser', Type: 'DeleteIDUser' },
2956
+ { Column: 'Code', Type: 'String', Unique: true },
2957
+ { Column: 'Scope', Type: 'String', UniqueGroup: 'ScopeHash' },
2958
+ { Column: 'Hash', Type: 'String', UniqueGroup: 'ScopeHash' },
2959
+ { Column: 'Name', Type: 'String' }
2960
+ ];
2961
+ const _WidgetJsonSchema = {
2962
+ title: 'Widget',
2963
+ type: 'object',
2964
+ properties: {
2965
+ IDWidget: { type: 'integer' },
2966
+ Code: { type: 'string' },
2967
+ Scope: { type: 'string' },
2968
+ Hash: { type: 'string' },
2969
+ Name: { type: 'string' }
2970
+ },
2971
+ required: ['IDWidget']
2972
+ };
2973
+ const _WidgetDefault = {
2974
+ IDWidget: 0,
2975
+ GUIDWidget: '0x0000000000000000',
2976
+ CreateDate: null,
2977
+ CreatingIDUser: 0,
2978
+ UpdateDate: null,
2979
+ UpdatingIDUser: 0,
2980
+ Deleted: 0,
2981
+ DeleteDate: null,
2982
+ DeletingIDUser: 0,
2983
+ Code: '',
2984
+ // Scope/Hash default to null so rows that only exercise the
2985
+ // single-column Unique on Code don't collide with each other
2986
+ // on the composite UNIQUE — SQLite treats each NULL in a
2987
+ // unique index as distinct, so NULL+NULL across many rows
2988
+ // is fine. Tests that exercise the composite supply real
2989
+ // values explicitly.
2990
+ Scope: null,
2991
+ Hash: null,
2992
+ Name: ''
2993
+ };
2994
+
2995
+ const _RenamePrefix = '__mdsd_';
2996
+
2997
+ suiteSetup
2998
+ (
2999
+ function (fDone)
3000
+ {
3001
+ _DB = _Fable.MeadowSQLiteProvider.db;
3002
+
3003
+ // Plain UNIQUE INDEX statements — no `WHERE Deleted=0`
3004
+ // partial-index syntax. Proof that the rename clears the
3005
+ // soft-deleted slot well enough that downstream schemas
3006
+ // don't need dialect-specific gymnastics.
3007
+ _DB.exec(
3008
+ `CREATE TABLE IF NOT EXISTS Widget (
3009
+ IDWidget INTEGER PRIMARY KEY AUTOINCREMENT,
3010
+ GUIDWidget TEXT NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000',
3011
+ CreateDate TEXT,
3012
+ CreatingIDUser INTEGER NOT NULL DEFAULT 0,
3013
+ UpdateDate TEXT,
3014
+ UpdatingIDUser INTEGER NOT NULL DEFAULT 0,
3015
+ Deleted INTEGER NOT NULL DEFAULT 0,
3016
+ DeleteDate TEXT,
3017
+ DeletingIDUser INTEGER NOT NULL DEFAULT 0,
3018
+ Code TEXT NOT NULL DEFAULT '',
3019
+ Scope TEXT,
3020
+ Hash TEXT,
3021
+ Name TEXT NOT NULL DEFAULT ''
3022
+ );
3023
+ CREATE UNIQUE INDEX IF NOT EXISTS widget_code_unique ON Widget(Code);
3024
+ CREATE UNIQUE INDEX IF NOT EXISTS widget_scope_hash_unique ON Widget(Scope, Hash);`
3025
+ );
3026
+
3027
+ _WidgetMeadow = libMeadow.new(_Fable, 'Widget')
3028
+ .setProvider('SQLite')
3029
+ .setSchema(_WidgetSchema)
3030
+ .setJsonSchema(_WidgetJsonSchema)
3031
+ .setDefaultIdentifier('IDWidget')
3032
+ .setDefault(_WidgetDefault);
3033
+
3034
+ _WidgetEndpoints = libMeadowEndpoints.new(_WidgetMeadow);
3035
+ _WidgetEndpoints.connectRoutes(_Orator.serviceServer);
3036
+
3037
+ return fDone();
3038
+ }
3039
+ );
3040
+
3041
+ test
3042
+ (
3043
+ 'INSERT with a value colliding with a soft-deleted row succeeds and renames the soft-deleted row',
3044
+ function (fDone)
3045
+ {
3046
+ // Setup: create a widget with Code=alpha and soft-delete it.
3047
+ _SuperTest
3048
+ .post('1.0/Widget')
3049
+ .send({ Code: 'alpha', Name: 'Original' })
3050
+ .end(
3051
+ (pPostErr, pPostRes) =>
3052
+ {
3053
+ Expect(pPostRes.status).to.equal(200);
3054
+ let tmpOriginal = JSON.parse(pPostRes.text);
3055
+ Expect(tmpOriginal.Code).to.equal('alpha');
3056
+ let tmpOriginalID = tmpOriginal.IDWidget;
3057
+
3058
+ _SuperTest
3059
+ .delete(`1.0/Widget/${tmpOriginalID}`)
3060
+ .end(
3061
+ (pDelErr, pDelRes) =>
3062
+ {
3063
+ Expect(pDelRes.status).to.equal(200);
3064
+ // Soft-deleted: row 1 still exists with Code=alpha and Deleted=1.
3065
+
3066
+ // Now insert a NEW widget with Code=alpha. The plain
3067
+ // UNIQUE INDEX would reject this without the rename;
3068
+ // with the rename, the soft-deleted row's Code gets
3069
+ // renamed deterministically and the new INSERT wins
3070
+ // the slot.
3071
+ _SuperTest
3072
+ .post('1.0/Widget')
3073
+ .send({ Code: 'alpha', Name: 'Replacement' })
3074
+ .end(
3075
+ (pNewErr, pNewRes) =>
3076
+ {
3077
+ Expect(pNewRes.status, `unexpected status ${pNewRes.status}; body=${pNewRes.text}`).to.equal(200);
3078
+ let tmpNew = JSON.parse(pNewRes.text);
3079
+ Expect(tmpNew.Code).to.equal('alpha');
3080
+ Expect(tmpNew.Name).to.equal('Replacement');
3081
+ Expect(tmpNew.IDWidget).to.be.above(tmpOriginalID);
3082
+
3083
+ // Verify the soft-deleted row was renamed
3084
+ // deterministically. Inspect via the raw DB
3085
+ // (bypassing the soft-delete filter the API
3086
+ // applies by default).
3087
+ let tmpRow = _DB.prepare('SELECT IDWidget, Code, Deleted FROM Widget WHERE IDWidget = ?').get(tmpOriginalID);
3088
+ Expect(tmpRow).to.exist;
3089
+ Expect(tmpRow.Deleted).to.equal(1);
3090
+ Expect(tmpRow.Code).to.match(new RegExp('^' + _RenamePrefix + '[0-9a-f]{16}$'));
3091
+
3092
+ // Determinism: recompute the expected suffix
3093
+ // from (IDRecord, Column, OriginalValue) and
3094
+ // check it matches.
3095
+ const libCrypto = require('crypto');
3096
+ const tmpExpected = _RenamePrefix + libCrypto.createHash('sha1').update(`${tmpOriginalID}:Code:alpha`).digest('hex').slice(0, 16);
3097
+ Expect(tmpRow.Code).to.equal(tmpExpected);
3098
+
3099
+ fDone();
3100
+ }
3101
+ );
3102
+ }
3103
+ );
3104
+ }
3105
+ );
3106
+ }
3107
+ );
3108
+
3109
+ test
3110
+ (
3111
+ 'Composite (Scope, Hash) collision: soft-deleted row gets BOTH columns renamed',
3112
+ function (fDone)
3113
+ {
3114
+ _SuperTest
3115
+ .post('1.0/Widget')
3116
+ .send({ Code: 'cmp1', Scope: 'TenantA', Hash: 'hashV1', Name: 'Composite original' })
3117
+ .end(
3118
+ (pPostErr, pPostRes) =>
3119
+ {
3120
+ Expect(pPostRes.status).to.equal(200);
3121
+ let tmpOriginal = JSON.parse(pPostRes.text);
3122
+ let tmpOriginalID = tmpOriginal.IDWidget;
3123
+
3124
+ _SuperTest
3125
+ .delete(`1.0/Widget/${tmpOriginalID}`)
3126
+ .end(
3127
+ (pDelErr, pDelRes) =>
3128
+ {
3129
+ Expect(pDelRes.status).to.equal(200);
3130
+
3131
+ _SuperTest
3132
+ .post('1.0/Widget')
3133
+ .send({ Code: 'cmp2', Scope: 'TenantA', Hash: 'hashV1', Name: 'Composite replacement' })
3134
+ .end(
3135
+ (pNewErr, pNewRes) =>
3136
+ {
3137
+ Expect(pNewRes.status, `unexpected status ${pNewRes.status}; body=${pNewRes.text}`).to.equal(200);
3138
+ let tmpNew = JSON.parse(pNewRes.text);
3139
+ Expect(tmpNew.Scope).to.equal('TenantA');
3140
+ Expect(tmpNew.Hash).to.equal('hashV1');
3141
+
3142
+ // Both Scope and Hash on the soft-deleted row
3143
+ // should now be __mdsd_-prefixed.
3144
+ let tmpRow = _DB.prepare('SELECT IDWidget, Scope, Hash, Deleted FROM Widget WHERE IDWidget = ?').get(tmpOriginalID);
3145
+ Expect(tmpRow.Deleted).to.equal(1);
3146
+ Expect(tmpRow.Scope).to.match(new RegExp('^' + _RenamePrefix + '[0-9a-f]{16}$'));
3147
+ Expect(tmpRow.Hash).to.match(new RegExp('^' + _RenamePrefix + '[0-9a-f]{16}$'));
3148
+
3149
+ const libCrypto = require('crypto');
3150
+ const tmpExpectedScope = _RenamePrefix + libCrypto.createHash('sha1').update(`${tmpOriginalID}:Scope:TenantA`).digest('hex').slice(0, 16);
3151
+ const tmpExpectedHash = _RenamePrefix + libCrypto.createHash('sha1').update(`${tmpOriginalID}:Hash:hashV1`).digest('hex').slice(0, 16);
3152
+ Expect(tmpRow.Scope).to.equal(tmpExpectedScope);
3153
+ Expect(tmpRow.Hash).to.equal(tmpExpectedHash);
3154
+
3155
+ fDone();
3156
+ }
3157
+ );
3158
+ }
3159
+ );
3160
+ }
3161
+ );
3162
+ }
3163
+ );
3164
+
3165
+ test
3166
+ (
3167
+ 'Live (non-deleted) row colliding on a unique index still errors — rename only fires for soft-deleted',
3168
+ function (fDone)
3169
+ {
3170
+ _SuperTest
3171
+ .post('1.0/Widget')
3172
+ .send({ Code: 'beta', Name: 'Live row' })
3173
+ .end(
3174
+ (pPostErr, pPostRes) =>
3175
+ {
3176
+ Expect(pPostRes.status).to.equal(200);
3177
+
3178
+ // No DELETE this time. Insert again with Code=beta —
3179
+ // should error because the live row holds the slot.
3180
+ _SuperTest
3181
+ .post('1.0/Widget')
3182
+ .send({ Code: 'beta', Name: 'Live collision' })
3183
+ .end(
3184
+ (pNewErr, pNewRes) =>
3185
+ {
3186
+ let tmpResult = JSON.parse(pNewRes.text);
3187
+ Expect(tmpResult).to.have.property('Error');
3188
+ fDone();
3189
+ }
3190
+ );
3191
+ }
3192
+ );
3193
+ }
3194
+ );
3195
+
3196
+ test
3197
+ (
3198
+ 'Round-trip: delete → POST-with-same-Code → re-read cleanly, no partial-index needed',
3199
+ function (fDone)
3200
+ {
3201
+ // End-to-end demonstration of the scenario downstream
3202
+ // schemas used to need `WHERE Deleted=0` to support.
3203
+ _SuperTest
3204
+ .post('1.0/Widget')
3205
+ .send({ Code: 'roundtrip', Name: 'gen-1' })
3206
+ .end(
3207
+ (pPostErr, pPostRes) =>
3208
+ {
3209
+ let tmpFirst = JSON.parse(pPostRes.text);
3210
+ Expect(tmpFirst.IDWidget).to.be.above(0);
3211
+
3212
+ _SuperTest
3213
+ .delete(`1.0/Widget/${tmpFirst.IDWidget}`)
3214
+ .end(
3215
+ (pDelErr) =>
3216
+ {
3217
+ _SuperTest
3218
+ .post('1.0/Widget')
3219
+ .send({ Code: 'roundtrip', Name: 'gen-2' })
3220
+ .end(
3221
+ (pPost2Err, pPost2Res) =>
3222
+ {
3223
+ Expect(pPost2Res.status).to.equal(200);
3224
+ let tmpSecond = JSON.parse(pPost2Res.text);
3225
+ Expect(tmpSecond.IDWidget).to.not.equal(tmpFirst.IDWidget);
3226
+
3227
+ _SuperTest
3228
+ .get(`1.0/Widget/${tmpSecond.IDWidget}`)
3229
+ .end(
3230
+ (pGetErr, pGetRes) =>
3231
+ {
3232
+ Expect(pGetRes.status).to.equal(200);
3233
+ let tmpReadBack = JSON.parse(pGetRes.text);
3234
+ Expect(tmpReadBack.Code).to.equal('roundtrip');
3235
+ Expect(tmpReadBack.Name).to.equal('gen-2');
3236
+ fDone();
3237
+ }
3238
+ );
3239
+ }
3240
+ );
3241
+ }
3242
+ );
3243
+ }
3244
+ );
3245
+ }
3246
+ );
3247
+
3248
+ test
3249
+ (
3250
+ 'Upsert (create branch) also benefits from the rename',
3251
+ function (fDone)
3252
+ {
3253
+ // Upsert with a new GUID + a Code that collides with a
3254
+ // soft-deleted row. The rename must run on the create
3255
+ // branch of the upsert too.
3256
+ _SuperTest
3257
+ .post('1.0/Widget')
3258
+ .send({ Code: 'upsertcollide', Name: 'upsert-original' })
3259
+ .end(
3260
+ (pPostErr, pPostRes) =>
3261
+ {
3262
+ let tmpOriginalID = JSON.parse(pPostRes.text).IDWidget;
3263
+ _SuperTest
3264
+ .delete(`1.0/Widget/${tmpOriginalID}`)
3265
+ .end(
3266
+ () =>
3267
+ {
3268
+ // IDWidget=0 forces the upsert path to a Create.
3269
+ _SuperTest
3270
+ .put('1.0/Widget/Upsert')
3271
+ .send({ IDWidget: 0, Code: 'upsertcollide', Name: 'upsert-new' })
3272
+ .end(
3273
+ (pUpErr, pUpRes) =>
3274
+ {
3275
+ Expect(pUpRes.status, `unexpected status ${pUpRes.status}; body=${pUpRes.text}`).to.equal(200);
3276
+ let tmpResult = JSON.parse(pUpRes.text);
3277
+ Expect(tmpResult.Code).to.equal('upsertcollide');
3278
+ Expect(tmpResult.Name).to.equal('upsert-new');
3279
+ Expect(tmpResult.IDWidget).to.not.equal(tmpOriginalID);
3280
+ fDone();
3281
+ }
3282
+ );
3283
+ }
3284
+ );
3285
+ }
3286
+ );
3287
+ }
3288
+ );
3289
+ }
3290
+ );
2739
3291
  }
2740
3292
  );